gridcn

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:

Positionaria-rowindex
Header row1
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

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 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, role="progressbar" with an aria-label and matching aria-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.

AreaAutomated 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 tabindexVerified — 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 flowVerified — 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 reorderVerified — 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 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.
  • 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