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.
