How to build an accessible Calendar in React
A role="grid" table with role="row"/role="gridcell" and a roving tabindex keeping exactly one day focusable. Navigation moves *real DOM focus*, not just the tabindex: the previous build only rotated the index, so focus stayed on the old button and the moment an arrow crossed a month boundary that button unmounted and focus fell to <body>, ejecting the user from the widget mid-navigation. React reuses the day buttons positionally across months, so the focus move is deferred a task and re-queried by date — landing on the reused node would silently focus whatever date now occupies that grid slot. Arrows move a day or a week, Home/End reach the week edges, PageUp/PageDown change month (Shift for year), and every move is clamped to min/max and skips predicate-disabled days rather than parking the cursor where Enter does nothing; the prev/next buttons disable at the bounds for the same reason. aria-selected sits on the button that takes focus, not the enclosing <td>, and is omitted rather than serialised as "false" on the other thirty cells. Today is computed from the *local* calendar rather than the UTC instant, so aria-current="date" no longer lands a day early east of UTC+12. The month is announced through a separate visually-hidden status region: the visible label is also the grid's accessible name, so announcing there would mutate the name of the container focus sits inside.
When to use a Calendar
- Showing an always-visible month grid for picking a single date (inline, not a popover)
- As the building block inside a date-picker or date-range-picker popover
When not to use it
- A compact form field where an inline grid wastes space — use DatePicker (trigger + popover)
- Selecting a contiguous date range — use DateRangePicker, which composes two calendars
Keyboard interactions
Role grid, verified at WCAG 2.2-AA.
ArrowLeftArrowRightArrowUpArrowDownHomeEndPageUpPageDownEnterSpace
Common mistakes
Avoid: Comparing dates with local-time getters (getHours/getDate) after constructing them with new Date(y, m, d)
Prefer: All arithmetic and comparisons use UTC getters/Date.UTC so DST and timezone offsets never shift a day
Local-time construction can land a midnight date on the previous day in negative-offset zones, breaking selection and range math
Example
<Calendar defaultValue={new Date(Date.UTC(2024, 5, 15))} />