CoreUI ONE — 250+ PRO components and 25+ admin templates for React, Angular, Vue, Bootstrap and Next.js, plus Data Grid, Scheduler, 4,300+ PRO icons and the Next.js boilerplate. One license. → See what you get

Focusable Is Not Selectable: A Keyboard Focus Model for Date Grids

CoreUI Calendar showing October and November 2026, weekends unavailable and a Monday-to-Friday range from October 19 to 23 selected

Open almost any date picker, press Tab until you reach the grid, and focus lands on a day. Which day it can land on, and which days the arrow keys can reach, comes down to one small decision that most implementations make without noticing: can an unavailable date receive focus?

The intuitive answer is no. A disabled date is disabled, so you render it as <button disabled>, the browser skips it, and you move on. It looks right in a demo. It stops being right as soon as a real product starts disabling dates: weekends, public holidays, fully booked days, a minimum notice period, a cut-off time.

This post describes the model we use in the CoreUI v6 Calendar, and why: a cell’s focusability depends only on the calendar’s limits. Everything else decides only whether the cell can be selected.

Table of Contents

Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


Two questions, not one

Every cell in a date grid answers two questions:

  1. Can I get here? Can the keyboard move focus to this cell, and can a screen reader user discover it?
  2. Can I pick this? Will Enter, Space or a click select this date?

When a single disabled flag answers both, they become the same question, and that is where the problems start.

What goes wrong when focus follows selectability

A page with nothing to pick has no tab stop

Take a booking calendar with weekends disabled and a month that is fully booked. Every day on the page is unavailable, so every day is unfocusable. A roving tabindex needs one cell with tabindex="0", and there is none. Press Tab and you jump straight past the calendar.

A mouse user sees a month of greyed-out days and understands it at a glance. A keyboard user gets nothing: no grid, no hint that it is there, and no way to page to the next month from inside it.

Unavailable dates disappear for screen reader users

With focus skipping disabled cells, the arrow keys jump from the 15th to the 18th. Sighted users see the 16th and 17th in grey. A screen reader user hears “15”, then “18”, and has no way to know that the two days in between exist and cannot be booked. “This date is unavailable” and “this date is not there” become the same thing.

Every new rule needs another workaround

Once focus has to avoid unselectable cells, the component needs code that finds somewhere else to put it:

  • on open, find the nearest month that has something selectable;
  • on every arrow key, search for the next enabled date;
  • in week, month and year views, decide which row or period inherits the tab stop.

Each of these is an approximation of the same fact, computed in a different place. Add a new way to disable dates and every one of them has to learn about it. Miss one, and the page without a tab stop is back.

How other libraries answer it

Before settling on a model, I read how the libraries I trust handle this, in their source code rather than their docs:

Library Unavailable date inside the range Date outside min/max
React Aria focusable, aria-disabled not focusable
Zag.js / Ark UI focusable (stays in the roving tabindex), Enter does nothing not focusable
MUI X <button disabled>, not focusable not focusable

MUI X follows the intuitive model, and its keyboard handler shows the cost: every arrow key goes through findClosestEnabledDate to find a date that focus is allowed to land on. React Aria and Zag.js keep the two questions apart. In React Aria the tab index comes from isDisabled (outside the limits, or the whole calendar disabled), while aria-disabled comes from !isSelectable, which also covers dates marked by isDateUnavailable.

The ARIA Authoring Practices Guide supports the second approach: inside composite widgets it recommends keeping disabled elements focusable when users need to discover them. A date grid is exactly that case.

Sources, read at a fixed commit: React Aria packages/react-aria/src/calendar/useCalendarCell.ts (react-spectrum 5a6c3250), Zag.js packages/machines/date-picker/src/date-picker.connect.ts (973921ea), MUI X packages/x-date-pickers/src/DateCalendar/DayCalendar.tsx (fcb9acd4).

The model

  • Focusable: the cell falls between the minDate day and the maxDate day. The comparison uses the date only and ignores the time of day.
  • Selectable: everything else decides this. Disabled dates, the time-of-day part of a limit, days from the adjacent month and whole week rows all count here.
  • An unselectable cell inside the range stays focusable and carries aria-disabled="true". Enter, Space and click do nothing on it, and the arrow keys move through it like any other day.

The two flags are computed independently, in one place:

function getCellState(date, { minDate, maxDate, isDateDisabled }) {
  const inRange = !isBeforeDay(date, minDate) && !isAfterDay(date, maxDate)
  const selectable = inRange && !isDateDisabled(date)

  return { focusable: inRange, selectable }
}

The rendered grid follows from that state. One cell holds tabindex="0", the other focusable cells get -1, and cells outside the range get no tabindex at all:

<td role="gridcell" tabindex="0" aria-label="Friday, October 16, 2026">16</td>
<td role="gridcell" tabindex="-1" aria-disabled="true" aria-label="Saturday, October 17, 2026">17</td>
<td role="gridcell" aria-disabled="true" aria-label="Monday, November 2, 2026">2</td>

Selecting asks the same function instead of reading attributes back from the DOM, so there is exactly one place that knows what can be picked:

grid.addEventListener('keydown', event => {
  if (event.key !== 'Enter' && event.key !== ' ') {
    return
  }

  const cell = event.target.closest('[role="gridcell"]')
  if (!cell) {
    return
  }

  event.preventDefault()
  const date = parseDate(cell.dataset.date)
  if (getCellState(date, options).selectable) {
    select(date)
  }
})

The workarounds from the previous section are no longer needed. The arrow keys move one day or one week at a time and stop at the limits. There is nothing to search for, because any date in the range can hold focus. A new way of disabling dates changes selectable and nothing else, and the focus code never has to know about it.

Why the limits are different

Why do minDate and maxDate still remove focus? Because they define what the calendar covers. They do not mark dates as unavailable inside it. A date before minDate is not booked; it is outside the range this calendar offers. Treating the limits as walls keeps keyboard navigation bounded. Without them, the arrow keys would walk forever through pages of dead cells.

The comparison is by day for a reason. If maxDate is today at 17:00, today is still reachable. Whether it can be picked at 18:00 is a question for selectability, not for focus.

Two places where we deliberately differ from React Aria

The navigation buttons stay enabled

React Aria disables “previous” and “next” when the adjacent page lies entirely outside the limits. That is logical, but as a user I would read a disabled “next month” button as a bug, because nothing tells me that everything over there is blocked. So our navigation stays enabled, and the information goes where everyone gets it:

  • when nothing on the current page can be picked, the grid gets an aria-describedby pointing to a short description: “Nothing on this page can be picked”;
  • the same note is announced when the user moves to that page.

The text is an option (ariaNothingToPickLabel), so it can be translated like every other label. None of the three libraries above describes a page with nothing to pick, so this part is our own.

An explicit page is not clamped to the limits

React Aria always constrains the focused date to the limits. If an application explicitly opens the calendar on a page that lies entirely outside them, we show that page. A mouse user sees a blocked month; a keyboard user should land on the same month and learn the same thing, not be moved somewhere else without a word. On such a page no cell is focusable, so the grid itself takes the tab stop, its description says nothing can be picked, and the arrow keys lead towards the limit.

Both decisions follow one principle: a keyboard user should learn what a mouse user sees.

One tab stop per view

The whole grid is a single Tab stop with a roving tabindex. With the model above, any page that overlaps the range always has a cell that can hold it, and on a page that doesn’t, the grid takes it. The same rule applies in the month, year and week views; in week selection the row is the focus target.

If you render on the server, decide which cell gets tabindex="0" during render, not in an effect that runs after hydration. Otherwise the HTML arrives without a tab stop, and Tab skips the calendar until the JavaScript has loaded.

Checklist

  • Derive focusability from minDate and maxDate only, at day precision.
  • Derive selectability from everything else, in one function.
  • Keep unselectable cells focusable, mark them with aria-disabled="true", and ignore Enter, Space and click on them.
  • Keep exactly one tab stop, and fall back to the grid when no cell is in range.
  • Don’t disable the navigation buttons because of the limits; describe a page that has nothing to pick instead.
  • Give every cell its full, localised date as its accessible name, and mark today with aria-current="date".

What I tested

I checked the “nothing to pick” description with VoiceOver on macOS in Safari. I haven’t measured NVDA or JAWS yet. If you have, I would like to hear what you find.

In CoreUI v6 the Calendar, Date Picker and Date Range Picker share this model in all three editions. The Accessibility section of each Calendar page lists every rule from this post, along with the full keyboard map:

Calendar Date Picker Date Range Picker
JavaScript Accessibility Docs Docs
React Accessibility Docs Docs
Vue Accessibility Docs Docs

About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.