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

When not to use it

Keyboard interactions

Role grid, verified at WCAG 2.2-AA.

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))} />

See the full Calendar reference →