gridcn1.3.0

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:

Positionaria-rowindex
Header rows: one per group level, then the column header row1 to the header row count
Pinned-top rows (data-grid-pinned-rows, if installed)2, 3, ...
Data rowscontinue after the pinned-top band
Pinned-bottom rowslast, 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 the loading prop is true.
  • A skeleton row, rendered when loading is true and there are zero rows yet, carries aria-busy="true" per row, plus role="presentation" and an aria-label (from labels.grid.loading) on the skeleton block as a whole.
  • A background refresh (loading true with rows already present) shows a slim progress bar instead. A visually hidden role="progressbar" sibling of the grid element announces it, with an aria-label and matching aria-valuetext.
  • A custom loadingState node 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.

Row markers
Name
Role
Score
Silent Tiger
Viewer
4
Quick Lion
Viewer
89
Swift Wolf
Manager
80
Eager Lion
User
51
Eager Tiger
Viewer
83
Silent Wolf
User
15
Swift Lion
Viewer
62
Eager Lion
Manager
42
Eager Eagle
Editor
35
Bold Tiger
Editor
52
Bold Bear
User
28
Swift Wolf
Manager
9
Swift Bear
Viewer
45
Eager Wolf
Editor
44
Swift Eagle
Viewer
51
Quick Lion
Manager
28
Bold Wolf
Editor
76
Swift Eagle
Admin
26
Eager Eagle
Manager
70
Eager Bear
Editor
67
Silent Lion
Manager
89
Silent Tiger
Manager
40
Eager Tiger
Manager
82
Silent Lion
Admin
16
Bold Eagle
Admin
38
Bold Lion
Viewer
3
Quick Tiger
Viewer
41
Bold Eagle
Editor
10
Swift Tiger
Editor
6
Silent Lion
Viewer
42

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.

PropTypeDefault
announceResultsbooleanfalse

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:

AreaAutomated 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 tabindexVerified: 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 flowVerified: 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 reorderVerified: 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 no aria-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 own region rule, 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-contrast rule, 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-live structure, not what a screen reader speaks aloud or when.

On this page