Accessibility
The WAI-ARIA grid model, roving tabindex, live regions, the honest test matrix, and known gaps.
Conformance target
The target is WCAG 2.1 AA. gridcn makes no conformance claim. No conformance audit has been performed. The automated checks are axe-core on one fixture in Chromium. Manual passes with NVDA (Windows) and VoiceOver (macOS and iOS) are pending, so no screen reader has been tested.
Semantics
Grid semantics
The scroll root carries role="grid". Each rendered row carries role="row". Each data cell
carries role="gridcell". Each header cell carries role="columnheader".
The aria index and count model
aria-rowcount and aria-colcount sit on the grid root. aria-rowindex sits on each row.
aria-colindex sits on each cell and header cell. Counts and indices are all 1-based, per the
WAI-ARIA grid pattern.
The row index layout accounts for the header and both pinned-row bands, in this order:
| Position | aria-rowindex |
|---|---|
| Header rows: one per group level, then the column header row | 1 to the header row count |
Pinned-top rows (data-grid-pinned-rows, if installed) | 2, 3, ... |
| Data rows | continue after the pinned-top band |
| Pinned-bottom rows | last, after every data row |
Each header row carries aria-rowindex. A grid without column groups has one header row. A column group adds one header row per group level, and the other indices shift down by the same number. A group header cell carries aria-colindex for its first column and aria-colspan for the columns it covers. A column that spans several header rows carries aria-rowspan.
aria-rowcount on the grid root always includes the header rows and both pinned bands, so a screen reader
announces the true total row count, not just the data row count.
The marker column (row numbers or checkboxes) sits outside this index space entirely. It
carries no aria-colindex, and no data column's aria-colindex shifts to make room for it.
aria-colcount counts only real data columns.
Sort and read-only state
Every sortable header cell carries aria-sort (ascending, descending, or none)
regardless of headerClickBehavior. The visual sort arrow renders only when
headerClickBehavior is "sort"; the attribute renders in every mode. The grid root carries
aria-readonly when the grid's readOnly prop is set.
Roving tabindex
The grid follows the WAI-ARIA APG roving-tabindex pattern for a single active cell. Before any
cell becomes active, the grid root itself is the one tabbable entry point (tabIndex={0}).
Once a cell becomes active, that cell alone carries tabIndex={0}, every other cell carries
tabIndex={-1}, and the root reverts to tabIndex={-1}. Real DOM focus follows the active
cell, including when it scrolls out of the rendered rows and back in. Focus never gets lost.
Pinned rows and skeleton (not-yet-loaded) cells carry no tabindex at all. They sit outside
roving-tabindex navigation, since neither is ever the active cell.
Selection and editor
Selection announcement model
Every selected cell carries aria-selected="true", for a single cell, a range, a row-channel,
or a column-channel selection alike. aria-multiselectable on the grid root reflects whether
more than one cell can be selected at once, true whenever any multi-cell selection channel
(range, row, column, or multi-range) is enabled.
Selection paint goes through an overlay layer. Membership changes update aria-selected on the
affected cells (a bounded, guarded re-render. See Performance for the
invariant wording). The aria-selected attribute is the accessibility-tree signal a screen
reader reads. The overlay is the visual signal for sighted users. The two agree on the same state.
Each data row carries aria-selected too New. A selected row reads aria-selected="true". With a checkbox row marker (rowMarkers="checkbox" or "both"), an unselected row reads aria-selected="false". Without one, an unselected row has no aria-selected. A screen reader then learns the state of a row from the row itself, not only from the marker checkbox.
Editor focus behavior
Opening a cell editor renders a real <input> (or other native control) inside the gridcell,
so assistive tech reads it as an ordinary form field. Closing the editor (commit or cancel)
returns DOM focus to the cell itself. Focus never lands on document.body or gets dropped.
aria-busy and loading
Three separate elements carry aria-busy or an equivalent progress role:
- The grid root carries
aria-busy="true"while theloadingprop is true. - A skeleton row, rendered when
loadingis true and there are zero rows yet, carriesaria-busy="true"per row, plusrole="presentation"and anaria-label(fromlabels.grid.loading) on the skeleton block as a whole. - A background refresh (
loadingtrue with rows already present) shows a slim progress bar instead. A visually hiddenrole="progressbar"sibling of the grid element announces it, with anaria-labeland matchingaria-valuetext. - A custom
loadingStatenode replaces the skeleton or the bar. It carries no built-in role or label. The node owns its own accessibility.
Live regions
Check rows with the markers or Ctrl+Shift+Space, or filter and sort a column. The line below shows the text a screen reader hears from the grid's live region.
The data-grid-toolbar search box announces its match count through role="status" with
aria-live="polite". The data-grid-toolbar filter popover and the data-grid-sort-list
popover each carry a role="status" aria-live="polite" region that announces every reorder,
for example "Name filter moved to position 2 of 3" or "Age sort moved to position 1 of 2"
(labels.toolbar.filterReorderAnnouncement / labels.sort.sortReorderAnnouncement).
Reordering itself is fully keyboard-operable. Each row's drag handle is focusable. A plain
ArrowUp or ArrowDown on a focused drag handle moves that row exactly one position,
and always wins over @dnd-kit/react's own keyboard-drag gesture on the same key. Focus stays
on the moved row's drag handle after a reorder, and moves to the next row's drag handle (or the "Add
filter"/"Add sort" button once the list is empty) after a delete. See the per-key tables for
both popovers: Toolbar add-on: filter menu keyboard &
accessibility and
Sort list: keyboard & accessibility.
The grid core has one live region New, a role="status" element with aria-live="polite". A live region cannot live inside role="grid", so the grid places it in <body>. The region reads a row move and, after a check or uncheck, the number of checked rows, for example "3 rows checked" (labels.grid.rowsChecked). It waits 500 ms after the last change. A marker range drag is read once, with its final count. The same text is read again when the same change repeats.
With announceResults New, the region also reads the number of rows in the view after a filter or sort change, for example "42 rows" (labels.grid.resultsCount). A server-sorted grid skips it, because the grid does not hold every row. A lazy source reads its loaded rows only, for example "42 loaded rows" (labels.grid.resultsCountLoaded). A change that leaves the rows and their order as they were, such as moving a filter row, stays quiet, and so does a filter applied on mount.
| Prop | Type | Default |
|---|---|---|
announceResults | boolean | false |
For the checked-row announcement, see Selection & keyboard.
Keyboard coverage
Selection & keyboard is the canonical shortcut reference for cell navigation, selection, and editing. This page does not repeat that table.
Tab and Shift+Tab move focus out of the grid to the next or previous focusable element, so the grid is never a keyboard trap. To navigate cells with Tab instead, bind it on the movement actions:
<DataGrid keymap={{ moveRight: ["ArrowRight", "Tab"], moveLeft: ["ArrowLeft", "shift+Tab"] }} />Test status matrix
What the automated Chromium suite verifies, area by area:
| Area | Automated Chromium suite |
|---|---|
| Grid semantics (roles, aria counts/indices) | Verified: a11y.browser.test.tsx, zero axe violations on the base fixture (toolbar + markers + pinned row + checkbox column + sorted column) |
| Keyboard navigation & roving tabindex | Verified: data-grid.browser.test.tsx: arrow keys move the active cell and scroll at the viewport edge. A real Tab enters the grid. The next Tab leaves it. Shift+Tab leaves backward |
| Editor open/close focus flow | Verified: a11y.browser.test.tsx (zero violations with an editor open). data-grid.browser.test.tsx (caret lands at the end of existing content, never select-all) |
Multi-cell selection (aria-selected, aria-multiselectable) | Verified: a11y.browser.test.tsx (aria-selected on every range member). row-aria-selected.browser.test.tsx (aria-selected on checked and unchecked rows) |
| Toolbar popovers + keyboard reorder | Verified: toolbar.browser.test.tsx, sort-list.browser.test.tsx: drag handle keeps focus on the moved row, announces the new position, full remove/refocus paths |
| Live regions (search count, reorder announcements) | Verified structurally. The aria-live regions are in the fixture's axe tree and the reorder tests assert the announcement text. Whether a screen reader speaks it aloud is out of scope |
What axe-core does and does not prove
The suite runs axe-core against the real rendered DOM in headless Chromium (Vitest Browser Mode) with a fixture carrying a toolbar, row markers, a pinned-top row, a checkbox column, and a sorted column: zero violations. axe-core is a static, structural checker: it does not confirm that a screen reader announces the right thing at the right moment.
Touch targets
Under a coarse pointer (@media (pointer: coarse)), every control a finger must hit is at least
24 by 24 CSS px (WCAG 2.2, SC 2.5.8):
- The selection handles.
- The column resize area.
- The column drag handle, which appears only when column reorder is on.
- The checkbox of a checkbox cell.
- The header menu button and the header filter button. Both stay visible.
Every touch gesture has another way to do the same thing. The context menu offers the clipboard, row, and fill items. A hardware keyboard extends a range with Shift+arrow.
Known gaps
- The fill handle is mouse-only and hidden from assistive tech. Dragging it has no keyboard equivalent. Keyboard users get the same result with Ctrl/Cmd+D (fill down) or Ctrl/Cmd+R (fill right). See Fill handle.
- Pinned rows are not keyboard-navigable and not in the selection model. Arrow keys, Tab, and
Ctrl+Arrow jumps move only within data rows. A pinned row is never the active cell, carries no
tabindex, and carries noaria-selected. See Pinned rows. - The grid renders no page landmark of its own. Place it inside
<main>or a labeled<section>yourself. Without a surrounding landmark, screen-reader region navigation, and axe's ownregionrule, cannot see the grid as part of a labeled page region. The automated suite's own fixture wraps the grid in a<main>for exactly this reason. - Color contrast is not covered by the automated suite. The a11y test suite disables axe's
color-contrastrule, because headless-Chromium font metrics make that rule unreliable in that environment, not because contrast does not matter. - No screen reader has been tested. The NVDA and VoiceOver passes are pending. Test your own grid with the screen readers your users run.
- Announcement timing is not covered. The tests assert the announcement text and the
aria-livestructure, not what a screen reader speaks aloud or when.