ESC

Type to search the knowledge base.

Calendar Month View

Machine-coding brief for a month calendar grid — date math, selection, keyboard nav, a11y, and localization hooks.

intermediate5 min read
  • machine-coding
  • interview
  • react
  • dates

Problem statement

Build a month calendar view: a 7-column grid for one month, with prev/next navigation and day selection. Interviewers care about date math edge cases (month boundaries, week start day), controlled selection, and keyboard accessibility — not a full scheduling product.

Requirements

Must have

  • Show one month at a time (header: Month YYYY)
  • 7-day week headers (configurable week start: Sunday or Monday)
  • Leading/trailing days from adjacent months (dimmed) or empty cells — pick one and stick to it
  • Click a day → select it (controlled or uncontrolled)
  • Prev / next month controls
  • Today indicator
  • Keyboard: arrows move focus day-to-day; Enter selects; PageUp/PageDown change month

Should have

  • minDate / maxDate disable out-of-range days
  • disabledDates set or predicate
  • Locale-aware month/weekday labels (Intl.DateTimeFormat)

Nice to have

  • Multi-select or range select
  • Show dots for “has events” without building full event UI
  • Year picker

Planning (5 minutes out loud)

  1. Canonical date — store as YYYY-MM-DD string or UTC-noon Date to avoid TZ bugs
  2. Grid builder — pure function (year, month, weekStartsOn) => Cell[]
  3. Focus vs selection — two concepts (roving tabindex)
  4. MVP — grid + select + prev/next; then keyboard + min/max
  5. No date library unless allowed — show you can do month math

Architecture

Calendar
├── CalendarHeader     // title + prev/next
├── WeekdayRow
├── CalendarGrid
│   └── DayCell
└── utils/calendarMath.ts

Data model

type DayCell = {
  date: string; // YYYY-MM-DD
  day: number;
  inCurrentMonth: boolean;
  isToday: boolean;
};

type CalendarProps = {
  value?: string | null; // selected YYYY-MM-DD
  defaultValue?: string | null;
  onChange?: (date: string | null) => void;
  month?: { year: number; month: number }; // 0-indexed month, controlled view
  defaultMonth?: { year: number; month: number };
  onMonthChange?: (m: { year: number; month: number }) => void;
  weekStartsOn?: 0 | 1; // 0 = Sunday
  minDate?: string;
  maxDate?: string;
  isDateDisabled?: (date: string) => boolean;
};

Date math sketch

function pad(n: number) {
  return String(n).padStart(2, "0");
}

export function toKey(y: number, m: number, d: number) {
  return `${y}-${pad(m + 1)}-${pad(d)}`;
}

export function parseKey(key: string) {
  const [y, m, d] = key.split("-").map(Number);
  return { year: y, month: m - 1, day: d };
}

export function daysInMonth(year: number, month: number) {
  return new Date(year, month + 1, 0).getDate();
}

export function buildMonthGrid(
  year: number,
  month: number,
  weekStartsOn: 0 | 1 = 0
): DayCell[] {
  const firstDow = new Date(year, month, 1).getDay(); // 0 Sun
  const startOffset = (firstDow - weekStartsOn + 7) % 7;
  const dim = daysInMonth(year, month);
  const today = new Date();
  const todayKey = toKey(today.getFullYear(), today.getMonth(), today.getDate());

  const cells: DayCell[] = [];
  // leading
  const prevDim = daysInMonth(year, month - 1);
  for (let i = startOffset - 1; i >= 0; i--) {
    const d = prevDim - i;
    const dt = new Date(year, month - 1, d);
    cells.push({
      date: toKey(dt.getFullYear(), dt.getMonth(), d),
      day: d,
      inCurrentMonth: false,
      isToday: false,
    });
  }
  for (let d = 1; d <= dim; d++) {
    const key = toKey(year, month, d);
    cells.push({
      date: key,
      day: d,
      inCurrentMonth: true,
      isToday: key === todayKey,
    });
  }
  // trailing to complete weeks (42 cells = 6 weeks is fine)
  let next = 1;
  while (cells.length % 7 !== 0 || cells.length < 42) {
    const dt = new Date(year, month + 1, next);
    cells.push({
      date: toKey(dt.getFullYear(), dt.getMonth(), next),
      day: next,
      inCurrentMonth: false,
      isToday: false,
    });
    next++;
    if (cells.length >= 42) break;
  }
  return cells;
}

Watch DST and “local midnight” pitfalls: constructing new Date("2026-08-04") parses as UTC in some engines. Prefer new Date(y, m, d) for local calendar math.

Implementation sketch

function DayButton({
  cell,
  selected,
  disabled,
  tabIndex,
  onSelect,
  onFocus,
}: {
  cell: DayCell;
  selected: boolean;
  disabled: boolean;
  tabIndex: number;
  onSelect: () => void;
  onFocus: () => void;
}) {
  return (
    <button
      type="button"
      role="gridcell"
      aria-selected={selected}
      aria-current={cell.isToday ? "date" : undefined}
      disabled={disabled}
      tabIndex={tabIndex}
      data-outside={!cell.inCurrentMonth || undefined}
      onClick={onSelect}
      onFocus={onFocus}
    >
      {cell.day}
    </button>
  );
}

Grid container:

<div role="grid" aria-label="Calendar">
  <div role="row">
    {weekdays.map((w) => (
      <div key={w} role="columnheader">
        {w}
      </div>
    ))}
  </div>
  {chunk(cells, 7).map((week, i) => (
    <div key={i} role="row">
      {week.map((cell) => (
        <DayButton key={cell.date} /* ... */ />
      ))}
    </div>
  ))}
</div>

Keyboard

  • Maintain focusedDate separate from selected
  • Only one day has tabIndex={0}; others -1 (roving tabindex)
  • ArrowLeft/Right ±1 day; ArrowUp/Down ±7; Home/End week edges
  • Crossing month boundary updates visible month

Accessibility essentials

  • Prev/next buttons need names: aria-label="Previous month"
  • Month title can be aria-live="polite" when month changes
  • Disabled days use disabled not only visual grey
  • Selected day: aria-selected="true"
  • Don’t rely on color alone for today vs selected

Performance notes

  • Rebuild grid only when year/month/weekStartsOn changes (useMemo)
  • 42 cells is trivial; no virtualization
  • Avoid creating new Date() in render for every cell if you can precompute keys

Footguns

  1. Timezone off-by-one on ISO date strings
  2. February / leap years wrong daysInMonth
  3. weekStartsOn math errors leave empty first row
  4. Selecting outside-month day without switching visible month (confusing UX)
  5. Focus lost after month change — restore focus to same day number or 1st

Interview out-loud answer

I’d separate view month from selected date. A pure buildMonthGrid fills six weeks with outside-month padding. Cells are buttons in a role="grid" with roving tabindex. Selection is controlled via YYYY-MM-DD strings to dodge timezone bugs. MVP is paint + click select + prev/next; then min/max and keyboard. I’d use Intl for labels if locale matters.

Further reading