Skip to main content

Lesson 5: Keyboard and Accessibility

Keyboard navigation, ARIA roles, and screen reader support.

Built-in keyboard navigation

getDayProps attaches an onKeyDown handler that implements the full WAI-ARIA grid keyboard pattern:

KeyAction
ArrowLeft / ArrowRightMove focus +/- 1 day
ArrowUp / ArrowDownMove focus +/- 1 week
PageUp / PageDownMove focus +/- 1 month
Shift+PageUp / Shift+PageDownMove focus +/- 1 year
Home / EndMove to start/end of week
Enter / SpaceSelect focused date
EscapeDismiss (calls onDismiss)

Spread getDayProps(day) on each day element and you get all of this.


Focus management

The calendar uses roving tabIndex:

  • The focused date has tabIndex=0.
  • Other days have tabIndex=-1.
  • Tab enters the grid on the focused date, then leaves.
  • Arrow keys move focus inside the grid.

Focus crossing a month boundary (e.g. ArrowRight on the last day) auto-advances the displayed month.


ARIA roles

The prop getters apply all required ARIA attributes:

// getGridProps() produces:
{
  role: 'grid',
  'aria-labelledby': '<header-id>',
  'aria-roledescription': 'calendar',
}
 
// getDayProps(day) produces:
{
  role: 'gridcell',
  'aria-label': 'Saturday, March 14, 2026',
  'aria-selected': true,
  'aria-disabled': false,
  'aria-current': 'date', // only for today
  tabIndex: 0, // or -1
}
 
// getNavProps('prev') produces:
{
  'aria-label': 'Go to previous month',
  disabled: false,
  onClick: () => { /* navigates */ },
}
// getGridProps() produces:
{
  role: 'grid',
  'aria-labelledby': '<header-id>',
  'aria-roledescription': 'calendar',
}
 
// getDayProps(day) produces:
{
  role: 'gridcell',
  'aria-label': 'Saturday, March 14, 2026',
  'aria-selected': true,
  'aria-disabled': false,
  'aria-current': 'date', // only for today
  tabIndex: 0, // or -1
}
 
// getNavProps('prev') produces:
{
  'aria-label': 'Go to previous month',
  disabled: false,
  onClick: () => { /* navigates */ },
}

Screen reader announcements

The internal useAnnouncer hook adds a visually hidden aria-live="polite" region. It announces:

  • Month changes: "March 2026"
  • Selection changes: "Selected Saturday, March 14, 2026"

Compound components render the announcer inside Calendar.Root automatically.

When using hooks directly, render the portal yourself:

const { announcer, ...rest } = useCalendar({ adapter, mode: 'single' })
 
return (
  <div>
    {/* ... calendar UI ... */}
    <announcer.AnnouncerPortal />
  </div>
)
const { announcer, ...rest } = useCalendar({ adapter, mode: 'single' })
 
return (
  <div>
    {/* ... calendar UI ... */}
    <announcer.AnnouncerPortal />
  </div>
)

Testing accessibility

Test keyboard navigation with Tab and arrow keys. Check screen reader output with VoiceOver (macOS) or NVDA (Windows).

Things to verify:

  • The grid announces as "calendar" when focused.
  • Each day announces its full date.
  • Selected days announce "selected".
  • Month changes are announced.
  • Disabled days announce as disabled.