Calendar Component

Building an accessible, headless calendar component with modern date arithmetic in React.

Mitchell Crompton

Mitchell Crompton

Calendar Component

Building a calendar component from scratch is a classic rite of passage in frontend engineering. While libraries like FullCalendar or React-Day-Picker exist, building your own primitive gives you total control over the DOM footprint, zero third-party baggage, and custom cell rendering.

In this post, we’ll walk through the architectural layers required to construct a modular monthly calendar: date matrix generation, keyboard accessibility (WAI-ARIA grid pattern), and virtualized month transitions.

The Date Matrix Algorithm

At its core, a monthly calendar view is an M × N matrix—typically 6 rows by 7 columns (42 cells total) to guarantee that any month’s overflow days before and after are cleanly accounted for.

We can compute the leading offset days from the previous month and trailing days from the next month using pure arithmetic:

export interface CalendarDay {
  date: Date;
  isCurrentMonth: boolean;
  isToday: boolean;
  isSelected: boolean;
}

export function generateCalendarMatrix(year: number, month: number): CalendarDay[] {
  const firstDayOfMonth = new Date(year, month, 1);
  const lastDayOfMonth = new Date(year, month + 1, 0);

  // Day index: 0 (Sun) to 6 (Sat)
  const startDayOffset = firstDayOfMonth.getDay();
  const totalDaysInMonth = lastDayOfMonth.getDate();

  const days: CalendarDay[] = [];

  // Previous month trailing days
  const prevMonthLastDate = new Date(year, month, 0).getDate();
  for (let i = startDayOffset - 1; i >= 0; i--) {
    days.push({
      date: new Date(year, month - 1, prevMonthLastDate - i),
      isCurrentMonth: false,
      isToday: false,
      isSelected: false,
    });
  }

  // Current month active days
  const today = new Date();
  for (let day = 1; day <= totalDaysInMonth; day++) {
    const current = new Date(year, month, day);
    days.push({
      date: current,
      isCurrentMonth: true,
      isToday: current.toDateString() === today.toDateString(),
      isSelected: false,
    });
  }

  // Next month leading days to complete the 42-cell grid
  const remainingCells = 42 - days.length;
  for (let day = 1; day <= remainingCells; day++) {
    days.push({
      date: new Date(year, month + 1, day),
      isCurrentMonth: false,
      isToday: false,
      isSelected: false,
    });
  }

  return days;
}

CSS Grid Layout

Using CSS Subgrid or standard CSS Grid, we can declare a strict 7-column layout with fixed aspect ratios for each day cell:

.calendar-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 1px;
  background-color: var(--borderColor);
  border: 1px solid var(--borderColor);
  border-radius: 4px;
  overflow: hidden;
}

.calendar-cell {
  background-color: var(--backgroundColor);
  aspect-ratio: 1;
  display: flex;
  flex-direction: column;
  padding: 8px;
  transition: background-color 0.1s ease;
}

.calendar-cell[data-outside-month="true"] {
  opacity: 0.35;
}

.calendar-cell:hover {
  background-color: var(--highlightColor);
  color: var(--invertedTextColor);
}

Keyboard Navigation & WAI-ARIA Patterns

A compliant date picker must behave as an accessible two-dimensional grid (role="grid"). Users expect standard navigation keystrokes:

Key Navigation Behavior
ArrowLeft / ArrowRight Moves focus to the previous or next day
ArrowUp / ArrowDown Moves focus backward or forward by 1 week (7 days)
Home / End Jumps to the first or last day of the current month
PageUp / PageDown Jumps backward or forward by exactly 1 full month
Enter / Space Selects the active day cell

Here is the event dispatcher pattern for keyboard trapping:

function handleKeyDown(e: React.KeyboardEvent, activeDate: Date) {
  const next = new Date(activeDate);

  switch (e.key) {
    case 'ArrowRight':
      next.setDate(next.getDate() + 1);
      break;
    case 'ArrowLeft':
      next.setDate(next.getDate() - 1);
      break;
    case 'ArrowDown':
      next.setDate(next.getDate() + 7);
      break;
    case 'ArrowUp':
      next.setDate(next.getDate() - 7);
      break;
    default:
      return;
  }

  e.preventDefault();
  setActiveDate(next);
}

Summary & Next Steps

Decoupling the date math from the presentation layer makes it straightforward to support range selection, localized week starts (Monday vs. Sunday via Intl.Locale), and multi-month views without rewriting any core layout logic.