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. No conformance audit has been performed against gridcn.
What the grid implements
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 row | 1 |
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 |
aria-rowcount on the grid root always includes 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.
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 window 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 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 /docs/performance for the
invariant wording). The aria-selected attribute is the accessibility-tree signal a screen
reader reads; the overlay is the sighted-user visual signal. The two agree on the same state.
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,role="progressbar"with anaria-labeland matchingaria-valuetext.
Toolbar and sort-list live regions
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 grip is focusable. A plain
ArrowUp or ArrowDown on a focused grip 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 grip after a reorder, and moves to the next row's grip (or the "Add filter"/"Add
sort" button once the list is empty) after a delete. See
Sorting, filtering & search for the full
per-key table for both popovers.
Keyboard coverage
Selection & keyboard is the canonical shortcut reference for cell navigation, selection, and editing. This page does not repeat that table.
Test status matrix
What the automated Chromium suite verifies, area by area.
| Area | Automated Chromium suite |
|---|---|
| Grid semantics (role, aria-rowcount/colcount/rowindex/colindex) | Verified — a11y.browser.test.tsx, "has zero violations on the base fixture (toolbar + markers + pinned row + sorted column)" |
| Keyboard navigation & roving tabindex | Verified — data-grid.browser.test.tsx: "arrow keys move the active cell and scroll the container when moving past the viewport edge", "a real Tab key press reaches the grid, then a second Tab leaves it (roving tabindex takes over)"; axe finds zero violations on the same base fixture |
| Editor open/close focus flow | Verified — a11y.browser.test.tsx, "has zero violations with a cell editor open"; data-grid.browser.test.tsx, "Enter on a focused text cell edits with the caret at the end of the existing content (never select-all)" |
Multi-cell selection (aria-selected, aria-multiselectable) | Verified — a11y.browser.test.tsx, "has zero violations with a multi-cell range selected (aria-selected on every member)" |
| Toolbar popovers (filter, sort-list, columns) + keyboard reorder | Verified — the base fixture mounts DataGridToolbar with search, filter menu, and columns menu, and axe finds zero violations against it; toolbar.browser.test.tsx and sort-list.browser.test.tsx each test "ArrowUp on the grip keeps focus on the moved row's grip and announces the new position", plus the full remove/refocus paths |
| Live regions (search match count, filter/sort reorder announcements) | Verified structurally — the fixture mounts the toolbar and axe checks the full accessibility tree, including the aria-live regions; the reorder tests above assert the announcement text itself, not just the DOM structure. axe does not confirm a screen reader speaks it aloud at the right moment |
What axe-core does and does not prove
The suite runs axe-core against a real rendered DOM in a
headless Chromium browser (Vitest Browser Mode), against a fixture with a toolbar, row markers,
a pinned-top row, and a sorted column. It finds zero violations across all three tests, three
files, three assertions, run 2026-08-01. axe-core is a static and structural checker: it catches
missing roles, invalid aria-* usage, contrast issues (disabled here — deferred to a real font
metrics pass, see the test file), and similar rule violations. It is a structural checker: it
does not confirm that a screen reader announces the right thing at the right moment.
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. - Announcement timing is not covered. The tests assert the announcement text and the
aria-livestructure, not what a screen reader speaks aloud or when.