Bridge view

Grid

An interactive 2D data structure where cells themselves are focusable — the APG escalation from `Table` (read-only data display) for surfaces where users navigate cell-by-cell, edit in place, or select rectangular regions. Carries `role="grid"` on the root, `role="row"` on rows, and `role="gridcell"` / `role="columnheader"` / `role="rowheader"` on cells. Keyboard model: roving-tabindex (one cell holds the tab stop), 2D arrow navigation, Home/End/Ctrl+Home/Ctrl+End for boundary jumps, F2 / Enter to enter edit-mode, Escape to cancel.

Also called Data grid Interactive grid Editable grid

When to use

Use

For tabular surfaces where cells are themselves the interactive units — spreadsheets, in-line-editable inventories, dense data-entry surfaces, focus-cell-driven data visualisations (cohort heatmaps with cell-level drilldown). Use the static variant when cells are interactive but not editable (clickable cells that open detail panels); use the editable variant when cells transition to input-mode for value editing. Pair with pagination or virtualization for large datasets; canonical-grid implementations support both via the `aria-rowcount` / `aria-colcount` attributes.

Avoid

For read-only tabular data where users scan and compare attributes via standard tab order through interactive descendants — that is `Table` (simpler keyboard model, less authoring overhead). For hierarchical rows with parent-child relationships and per-row expand / collapse — that is `TreeGrid`. For form-style input grids where each cell is a labeled form control — use a real form with grouped inputs, not Grid; the grid pattern is for cells-as-data, not cells-as-form-fields. For non-tabular 2D layouts (image galleries, dashboards) — those are CSS Grid layouts, not the WAI-ARIA grid pattern.

Versus related

  • table

    `Table` is read-only data display with linear focus through interactive descendants (links, buttons, checkboxes inside cells); standard Tab order navigates between focusables. `Grid` makes cells themselves focusable with 2D arrow-navigation and roving-tabindex (one tab-stop into the grid). Use Table when the cell content is interactive but the cells themselves are not (a "Delete" button per row); use Grid when cells are the unit of interaction (spreadsheet-style cell focus, rectangular selection, in-place edit). The escalation cost is real — Grid's keyboard model requires roving-tabindex bookkeeping plus F2 / Enter / Escape edit-lifecycle handling that Table avoids.

  • tree-grid

    `TreeGrid` adds hierarchical row semantics on top of the Grid keyboard model — `aria-level` per row, `aria-expanded` on parent rows, ArrowRight expands a collapsed parent / moves into first child, ArrowLeft collapses / moves to parent. Use TreeGrid for org charts, file managers with column metadata, nested budgets where rows have parent-child relationships; use Grid for flat tabular data with interactive cells.

  • combobox

    `Combobox` is a single-input affordance with a listbox of selectable options (one-dimensional linear list); `Grid` is a 2D structure with cells in rows and columns (two-dimensional spatial navigation). Combobox suits "pick one from a list"; Grid suits "navigate cell-by-cell through structured data". Their keyboard models diverge — Combobox uses ArrowDown / ArrowUp for list traversal, Grid uses all four arrows for cell navigation.

Grid is the APG-canonical pattern for interactive tabular data — spreadsheets, editable inventories, focus-cell-rich data visualisations. Distinct from `Table` (the canonical read-only data-display pattern with linear focus through interactive descendants) and from `TreeGrid` (hierarchical rows with `aria-expanded` per parent). The reference documents the roving-tabindex contract (single tab-stop into the grid; arrows move focus between cells), the F2 / Enter / Escape edit-mode lifecycle, the `aria-rowcount` / `aria-rowindex` / `aria-colcount` / `aria-colindex` rules for virtualized grids, and the boundary with Table (escalate from Table to Grid only when interactive cells justify the steeper keyboard model).

Highlight
Fig 1.1 · Grid · Bridge view
Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    Cells drawn as static text without focus-affordance

    Code

    `role="gridcell" tabindex="-1"` (or `tabindex="0"` for the active cell) plus 2D arrow-key handler

    Consequence

    Designers compose grid cells as static text in a tabular layout; developers must add the roving- tabindex keyboard model so cells are focusable. Without explicit anatomy, the focus-affordance is invisible in the design file — implementations may ship the visual without the keyboard model and AT users get standard tab-through behaviour that is not the canonical grid pattern. Sighted- mouse users notice nothing; keyboard + AT users lose the cell-by-cell navigation contract.

    Correct

    Document each cell as an interactive slot with explicit focus-state (default / focused / selected / editing). Figma carries a "focused" variant of the cell instance with the canonical focus outline + roving-tabindex semantic noted in the anatomy. Code wires `tabindex="-1"` on inactive cells, `tabindex="0"` on the active cell, and arrow-key handlers that update which cell holds the active tab-stop.

  2. 02
    Figma

    Edit-mode drawn as a separate frame replacing the cell

    Code

    In-place input replacement — cell-editor slot mounts inside the cell, not in a separate region

    Consequence

    Designers compose edit-mode as a modal-like separate frame ("Edit Cell" panel beside or below the grid); developers ship in-place input replacement (the cell becomes an `<input>` in the same DOM position). The two surfaces diverge structurally — designer's separate frame loses the spatial context of the cell, developer's in-place edit preserves it. Without explicit anatomy, the edit-mode pattern is ambiguous and implementations may ship either.

    Correct

    Document the cell-editor slot as a child of the cell, not a separate region. Figma carries an "editing" variant of the cell instance with the editor input mounted inside the cell's bounding box. Code mounts `<input>` / `<select>` inside the cell's `<td>` element on edit-mode entry; the cell's role and ARIA attributes shift to delegate-to-input semantics. Modal-style edit panels are a separate pattern (escalation to a Modal or Drawer for bulk-edit); in-place is the canonical Grid edit-mode shape.

  3. 03
    Figma

    Selection highlight drawn as solid background on selected cells

    Code

    Visual treatment plus `aria-selected="true"` on the cell or row

    Consequence

    Designers compose selection as a solid background color on selected cells; developers ship the same visual plus `aria-selected="true"` so AT users hear "selected". Without explicit anatomy, the AT-semantic may ship without `aria-selected` and the visual works for sighted users only. Multi-cell rectangular selection in particular tends to ship as visual-only because the multi-cell pattern is complex.

    Correct

    Document selection-overlay as a decorative slot AND `aria-selected` per cell / row as a contract. Figma carries the selection visual treatment as a state of the cell instance; the design surface signals "this visual reflects a programmatic state". Code sets `aria-selected="true"` on every selected cell or row alongside the visual treatment. For multi-cell rectangular selection, every cell in the rectangle carries `aria-selected="true"`.

  4. 04
    Figma

    Header row drawn identically to data rows

    Code

    `role="columnheader"` / `role="rowheader"` semantics distinct from `role="gridcell"`

    Consequence

    Designers compose grids with header rows styled distinctly (bolder text, sunken background) but no anatomy slot for the header semantic; developers must wire `role="columnheader"` / `role="rowheader"` so AT users hear the header context. Without explicit anatomy, headers may ship as plain `gridcell` and the data-cell-to-header association breaks programmatically.

    Correct

    Document column-header and row-header as distinct interactive slots from cell. Figma carries column-header and row-header as separate component instances with their own visual treatments; the design surface signals the role-distinction. Code wires `<th role="columnheader" scope="col">` for column headers, `<th role="rowheader" scope="row">` for row headers, and `<td role="gridcell">` for data cells.

  5. 05
    Figma

    Grid drawn without explicit roving-tabindex documentation

    Code

    Single tab-stop into grid; arrow keys move focus between cells

    Consequence

    Designers compose grids as visual-only tabular surfaces without surfacing the roving-tabindex contract; developers must implement the keyboard model (one `tabindex="0"` cell at a time, arrow keys shift it, Tab exits the grid). Without explicit anatomy, implementations may ship every cell as `tabindex="0"` (every cell is a tab-stop, totally broken keyboard model) or no cells as `tabindex="0"` (cells unreachable via keyboard). The roving- tabindex contract is the highest-load-bearing design-vs-code translation.

    Correct

    Document the roving-tabindex contract as a first-class anatomy slot property. Figma carries the focused-cell variant explicitly with annotations indicating the tab-stop semantic. Code wires roving- tabindex bookkeeping on every arrow-key navigation — the cell receiving focus gains `tabindex="0"`, the cell losing focus drops to `tabindex="-1"`. Tab from outside enters the grid at the active cell; Tab inside the grid exits to the next document focusable.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

static editable

Properties

The same component, parameterised.

PropertyType
size sm | md
multiSelect boolean
virtualized boolean
stickyHeader boolean
sortable boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactive
data
idleeditinghasSelectionhasSortingloading
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps static / editable. Editable activates F2/Enter/Escape edit-mode lifecycle and cell-editor slot mounting.
SizeEnumsizesm / md.
Multi SelectBooleanmultiSelectWhen true, supports multi-cell selection via Shift+Arrow / Ctrl+Click; root carries `aria-multiselectable="true"`.
VirtualizedBooleanvirtualizedWhen true, only viewport-visible rows render; requires `aria-rowcount` / `aria-colcount` on root and `aria-rowindex` / `aria-colindex` per rendered cell.
Sticky HeaderBooleanstickyHeaderWhen true, column-headers-row uses `position: sticky` so headers stay visible during vertical scroll.
SortableBooleansortablePer-column sort affordance plus `aria-sort` on column headers.
Row CountNumberrowCountTotal row count (for virtualized grids); reflects to `aria-rowcount`. Omitted for non-virtualized grids — implicit DOM count suffices.
Column CountNumbercolCountTotal column count (for virtualized grids); reflects to `aria-colcount`. Omitted for non-virtualized grids.
Both

State transitions

FromToTrigger
idleeditingUser presses F2 or Enter on a focused editable cell (editable variant only)
editingidleUser presses Enter (commit) or Escape (cancel) inside the cell-editor
idlehasSelectionUser activates selection via Space (single-cell), Shift+Arrow (range), or Ctrl+Click (multi)
idlehasSortingUser activates a sortable columnheader via Enter / Space
idleloadingAsync fetch begins (server-side sort, filter, or pagination)
loadingidleAsync fetch resolves with new data
Designer

Figma anatomy

Slot Figma type Hint
root frame Grid frame; variant property switches between static and editable shapes
caption text Caption text style; positioned above the grid; visibility per "Visible Caption" property
column-headers-row frame Header row container; sticky-position variant per "Sticky Header" property
column-header instance Column header cell instance with sort-affordance + resize-handle variants
row instance Row instance with selected / hover / focused-cell variants
row-header instance Row header cell instance; structural variant of cell with rowheader role
cell instance Data cell instance with default / focused / selected / editing / readonly variants
cell-editor instance Cell editor instance — input / select / textarea variants per cell-data-type
selection-overlay frame Decorative overlay rectangle; absolute-positioned over selected cell range
Dev

Code anatomy

Slot Code slot Semantic
root root grid-or-table-with-role-grid
caption caption caption-or-aria-labelledby
column-headers-row column-headers-row tr-or-row
column-header column-header th-with-role-columnheader
row row tr-with-role-row
row-header row-header th-with-role-rowheader
cell cell td-with-role-gridcell
cell-editor cell-editor input-or-select-or-textarea
selection-overlay selection-overlay presentational
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-grid>` host with `variant`, `size`, `multi-select`, `virtualized`, `sticky-header`, `sortable`, `editable` attributes. Renders `role="grid"` shadow tree with slotted `<ui-grid-row>` and `<ui-grid-cell>` children. The host owns roving-tabindex bookkeeping (one `tabindex="0"` cell at a time) and arrow-key handlers; cells expose their value via attributes. Wire-name `ui-grid` becomes `md-grid` / `mat-grid` / `pf-grid` per design system; canonical wire-name stays `grid`. Attributes drive variant + properties; `editable` activates the F2 / Enter / Escape lifecycle plus cell-editor slot mounting. Selection state surfaces via `selection-change` CustomEvent; sort state via `sort-change`; edit lifecycle via `cell-edit-start` / `cell-edit-commit` / `cell-edit-cancel`.
React AG Grid (most common production grid library) handles roving-tabindex, virtualization, and edit lifecycle out of the box. React Aria ships `useGrid` + `useGridCell` + `useGridRow` hooks for headless composition with explicit ARIA wiring. Glide Data Grid is canvas-based (different rendering model entirely; AT support requires explicit canvas-accessibility-tree). TanStack Table provides headless logic (sorting, filtering, virtualization) but does not implement the grid keyboard model — pair with React Aria hooks for canonical grid-pattern compliance. Props with class-variance-authority for variant + size; `editable: boolean` toggles cell-editor mounting; `multiSelect: boolean` drives `aria-multiselectable` plus `aria-selected` per cell. Cell renderers (column.cell or column.editor function props) customize per-cell rendering; the grid library handles the keyboard model.
Angular (signals) Angular Material's CDK provides `cdk-table` with `role="grid"` opt-in; AG Grid Angular wraps the AG Grid core. `MatTable` does not ship the grid keyboard model out of the box — escalation to AG Grid is the canonical Angular path for the Grid pattern. Signal- driven cell editing via `model<CellValue>('cellValue')` per cell. `[attr.role]="'grid'"` plus `[attr.aria-multiselectable]` reflective bindings; signal-derived `effect()` updates the roving-tabindex on arrow-key events. CSS variables drive size scaling; `prefers-reduced-motion` affects cell-edit-transition animations.
Vue AG Grid Vue, PrimeVue DataTable (with grid-mode opt-in), Element Plus el-table (with selectable + editable variants). No first-class WAI-ARIA grid-pattern primitive in the major Vue UI libraries — production Vue grids tend to use AG Grid Vue or build custom on top of `<table role="grid">` plus a cell-keyboard-handler composable. `defineProps` with literal-union types (`variant: 'static' | 'editable'`); v-model for selection and per-cell value bindings. Scoped CSS handles selection-overlay visuals; the keyboard model is composable via VueUse's focus-management primitives.
Both

Events

  1. selectionChange
    Payload
    `{ selectedCells: Set<{rowId, columnId}>, selectedRows: Set<rowId>, isAllSelected: boolean }`. Fires when selection state changes via Space, Shift+Arrow, Ctrl+Click, or programmatic selection. Distinguishes cell-level from row-level selection so consumers can react appropriately.
    Web Components
    `selectionChange` CustomEvent with `event.detail = { selectedCells, selectedRows, isAllSelected }`.
    React
    `onSelectionChange(selection)` callback (React Aria) or `onCellSelectionChange(state)` (AG Grid).
    Angular Signals
    `output<GridSelectionState>('selectionChange')` plus two-way `model('selection')`.
    Vue
    `@update:selection` (v-model:selection).
  2. cellEditStartoptional
    Payload
    `{ rowId: string, columnId: string, currentValue: unknown }`. Fires when the user enters edit-mode on a cell via F2 or Enter. Editable variant only.
    Web Components
    `cellEditStart` CustomEvent with `event.detail = { rowId, columnId, currentValue }`.
    React
    `onCellEditStart({ rowId, columnId, value })` callback.
    Angular Signals
    `output<{ rowId: string; columnId: string; currentValue: unknown }>('cellEditStart')`.
    Vue
    `@cell-edit-start` event.
  3. cellEditCommitoptional
    Payload
    `{ rowId: string, columnId: string, previousValue: unknown, newValue: unknown }`. Fires when the user commits an edit via Enter or Tab from inside the cell-editor. Consumer updates the data model and the grid re-renders the cell with the new value.
    Web Components
    `cellEditCommit` CustomEvent with `event.detail = { rowId, columnId, previousValue, newValue }`.
    React
    `onCellEditCommit({ rowId, columnId, value })` callback.
    Angular Signals
    `output<{ rowId: string; columnId: string; previousValue: unknown; newValue: unknown }>('cellEditCommit')`.
    Vue
    `@cell-edit-commit` event.
  4. cellEditCanceloptional
    Payload
    `{ rowId: string, columnId: string }`. Fires when the user cancels an edit via Escape from inside the cell-editor. Editable variant only.
    Web Components
    `cellEditCancel` CustomEvent with `event.detail = { rowId, columnId }`.
    React
    `onCellEditCancel({ rowId, columnId })` callback.
    Angular Signals
    `output<{ rowId: string; columnId: string }>('cellEditCancel')`.
    Vue
    `@cell-edit-cancel` event.
  5. sortChangeoptional
    Payload
    `{ columnId: string, direction: 'asc' | 'desc' | 'none' }` (single-sort) or `Array<{ columnId, direction }>` (multi-sort). Same shape as Table's sortChange event.
    Web Components
    `sortChange` CustomEvent with `event.detail = sortState`.
    React
    `onSortingChange(state)` (React Aria) or `onSortChanged(event)` (AG Grid).
    Angular Signals
    `output<SortState>('sortChange')`.
    Vue
    `@update:sort` (v-model:sort).
Both

Form integration

name attribute
Grid is application state, not a form control. Even the editable variant treats cell-editor inputs as transient affordances — the underlying data model lives in component state or the consumer's application store. Form submissions that depend on grid data read the model at submit time, not via grid-as-form-element.
Both

Performance thresholds

  • cellFocusLatencytime-to-focus16ms

    Arrow-key navigation must update the active cell within one frame (16.67ms on a 60Hz display) for the keyboard model to feel responsive. Above the threshold, users perceive lag and over-press the arrow key (skipping cells). The roving-tabindex bookkeeping plus the focus-change DOM update must fit within the budget. AG Grid and React Aria both meet this on commodity hardware; canvas-based grids (Glide Data Grid) sub-frame consistently.

  • virtualizationThresholdrow-count100rows

    Above ~100 simultaneously-rendered rows, the cumulative DOM-layout cost exceeds the threshold where keeping all rows in the tree is cheaper than virtualization bookkeeping. Editable grids hit this threshold faster than read-only grids because each cell carries an event handler and a roving-tabindex slot — DOM-cost per cell is higher.

  • cellEditTransitionLatencytime-to-editor-mount100ms

    F2 / Enter activation to cell-editor mount must complete within 100ms for the edit-mode entry to feel responsive. Above the threshold, users wonder whether the activation registered and over-press the key. The cell-editor library should pre- mount or memoize editor components for common cell types (text, number, select) so the activation is a state flip, not a mount.

Both

Internationalisation

RTL · mirroring

Column flow follows logical direction — leading column (typically row-identity) sits at the inline-start, which flips between LTR and RTL. Arrow-key navigation flips accordingly: ArrowRight under RTL moves to the inline-end column (visually left); APG explicitly recommends mapping arrow keys to the visual direction so the keyboard model stays intuitive in both writing systems. Sticky-first-column logic uses `inset-inline-start: 0`. Numeric cells text-align logical-end; text cells logical-start.

Text expansion

Column headers expand 30-50% under translation ("Quantity" → "Hoeveelheid" 100% longer in Dutch). Long headers force wider columns and may push the grid past viewport width — the scrollable-region wrapper (inherited from the Table contract) absorbs this. Cell content expands similarly; cell-editor inputs need flexible sizing so long values do not truncate on edit-mode entry. Numeric cells stable across locales via `Intl.NumberFormat`.

Both

Accessibility

Slot Accessibility hint
root `<table role="grid" aria-rowcount="..." aria-colcount="..." aria-multiselectable="...">`. Native `<table>` element retains the structural HTML semantics (rowgroups, header association) while the explicit `role="grid"` signals the interactive keyboard model. For non-tabular layouts where `<table>` is not appropriate, `<div role="grid">` is the canonical fallback. AT users hear "grid, N rows, M columns" on entry.
caption `<caption>` is the canonical accessible name for grids using `<table role="grid">`. For `<div role="grid">` implementations, use `aria-labelledby` pointing to a sibling heading id. SR announces the caption on grid entry. Translate per locale.
column-headers-row `<tr role="row">` containing `<th role="columnheader" scope="col">` children (or `<div role="row">` + `<div role="columnheader">` for non-table grids). The columnheader role plus `scope="col"` carries the data-cell association. Columnheaders are focusable in the grid keyboard model — arrow keys navigate to them like any other cell.
column-header `<th role="columnheader" scope="col" tabindex="-1">` — `tabindex="-1"` for non-active cells in the roving-tabindex scheme; the active cell carries `tabindex="0"`. `aria-sort="ascending"` / `"descending"` / `"none"` when sortable. The cell's accessible name is its text content; sortable headers should embed the column-name in a `<button>` or announce the sort affordance via `aria-describedby`.
row `<tr role="row" aria-rowindex="3">` (or `<div role="row">` for non-table grids). `aria-rowindex` is required for virtualized grids; for non-virtualized, the implicit DOM position suffices. `aria-selected` on the row when row-level selection is active; row-level activation typically comes from clicking a row-checkbox or pressing Space on a focused row-header.
row-header `<th role="rowheader" scope="row" tabindex="-1">`. SR announces the row-header on cell-by-cell navigation within the row, providing row context the same way columnheader provides column context. Optional — spreadsheet-style grids without semantic row identity (just numbered rows) omit this slot.
cell `<td role="gridcell" tabindex="-1">` for non-active cells; the active cell has `tabindex="0"`. Activating (F2 / Enter) transitions to edit-mode in the editable variant; activating in static variant triggers the cell's onClick or no-op. `aria-colindex` for virtualized grids (1-based); `aria-selected` for selection-state; `aria-readonly="true"` for non-editable cells in editable grids.
cell-editor Native `<input>` / `<select>` / `<textarea>` carries the right keyboard model and AT semantics without ARIA. On edit-mode entry, focus moves to the editor (replacing the cell's roving-tabindex slot). The editor's `<label>` is the cell's accessible name — canonically derived from the column header. On Escape, focus returns to the cell with the original value preserved. On Enter or Tab-out, the new value commits and focus returns to the cell (or the next cell on Tab).
selection-overlay `aria-hidden="true"`. The overlay is visual scaffolding for sighted users; the AT semantic lives on `aria-selected` per cell and per row. Spreadsheet-style multi-cell selection rectangles use this slot for the visible bounding box; AT users hear "selected" cell-by-cell as they navigate.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus enters the grid at the active cell (the one carrying `tabindex="0"`). On first entry, the active cell is the first data cell or the most-recently- focused cell from the prior visit. Tab again exits the grid to the next document focusable — Tab does NOT navigate between cells.
Shift+TabReverse focus — Shift+Tab from inside the grid exits to the previous document focusable. Shift+Tab from outside the grid enters at the active cell from the end of the document.
ArrowUp / ArrowDownMoves focus to the cell in the previous / next row, same column. The roving-tabindex shifts — the leaving cell drops to `tabindex="-1"`, the entering cell gains `tabindex="0"`. Wraps at boundaries per APG (or stops; document the canonical choice — wrapping is more common in spreadsheet-style grids, stopping in data-display grids).
ArrowLeft / ArrowRightMoves focus to the previous / next cell in the same row. Same roving-tabindex shift as vertical navigation.
Home / EndHome moves focus to the first cell in the current row; End to the last cell in the current row. Standard APG grid keyboard model.
Ctrl+Home / Ctrl+EndCtrl+Home moves focus to the first cell of the entire grid (typically row 1, column 1); Ctrl+End to the last cell. For virtualized grids, the virtualizer scrolls the target into view before focus lands.
Page Up / Page DownMoves focus by a viewport's worth of rows (the visible-row-count at the current size). For virtualized grids, the virtualizer scrolls accordingly.
F2 (focus on editable cell)Editable variant only. Enters edit-mode — the cell-editor mounts, focus moves from the cell to the editor. Static variant: F2 is a no-op (or focuses the first interactive descendant if any).
Enter (focus on editable cell)Same as F2 — enters edit-mode. From inside the cell-editor, Enter commits the new value and exits edit-mode; focus returns to the cell with the new value. From inside a `<textarea>` cell-editor, Enter inserts a newline instead — Ctrl+Enter or Tab commits.
Escape (focus inside cell-editor)Cancels the edit. The cell's value reverts to its pre-edit state; the cell-editor unmounts; focus returns to the cell.
Tab (focus inside cell-editor)Commits the new value AND advances focus to the next cell (or to the next document focusable if the active cell is the last in the grid). The commit-on-Tab behaviour mirrors spreadsheet-style edit lifecycle.
Space (focus on cell)Toggles cell selection when cell-level selection is supported. For row-level selection grids, Space on a focused cell toggles the row's selection. `aria-selected` flips; selectionChange fires.
Shift+Arrow (focus on cell)Extends the selection rectangle in the direction of the arrow. Multi- cell rectangular selection semantic. The selection-overlay visual updates; every cell in the rectangle gets `aria-selected="true"`.

Screen-reader announcements

TriggerExpected
SR enters the gridSR announces "grid, ${captionText}, ${rowCount} rows, ${columnCount} columns" using the caption text plus `aria-rowcount` / `aria-colcount` for virtualized grids. The user can navigate from there.
SR navigates to a cellSR announces "${columnHeader}, ${rowHeader}, ${cellValue}, row ${rowIndex} of ${rowCount}, column ${colIndex} of ${colCount}". Header context comes from `role="columnheader"` / `role="rowheader"` association; the row / column index from `aria-rowindex` / `aria-colindex`.
User enters edit-mode (editable variant)SR announces the cell-editor's role and accessible name — "edit, text input, Quantity, 12" (the editor is a `<input type="number">` with the column-header- derived label). Focus inside the editor; SR continues with the editor's keyboard model.
User commits an edit (Enter or Tab from editor)Edit-mode exits; focus returns to the cell with the new value. SR announces the cell's updated content via the focus-change announcement. Some SR / browser combinations also announce the commit explicitly via the cell-editor's `aria-live` confirmation if authored.
User cancels an edit (Escape)Edit-mode exits; focus returns to the cell with the original value preserved. SR announces the cell's content (the pre-edit value) via focus-change.
User toggles cell selection (Space)SR announces the new state — "selected" or "not selected". `aria-selected` flips on the cell or row; the visual treatment updates.

axe-core rules to assert

  • aria-allowed-attr
  • aria-required-attr
  • aria-required-children
  • aria-required-parent
  • aria-valid-attr-value
  • color-contrast
  • scope-attr-valid
  • td-headers-attr
  • th-has-data-cells

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/grid-pattern/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. APGAPG: Grid pattern keyboard interaction

    The grid implements roving-tabindex — only one cell carries `tabindex="0"` at a time; all other cells carry `tabindex="-1"`. Tab from outside enters the grid at the active cell; Tab inside the grid exits to the next document focusable. Implementations that ship every cell as `tabindex="0"` or no cells as `tabindex="0"` violate the APG grid keyboard model.

    Without roving-tabindex, the grid is either a Table-with-broken-tab-model (every cell a tab stop, breaking the single-tab-into-grid contract) or a keyboard-trap (no cells reachable). The APG grid pattern's whole point is the roving-tabindex keyboard model; without it, `role="grid"` is misleading.

  2. APGAPG: Grid pattern keyboard interaction

    Arrow keys move focus between cells — ArrowUp / ArrowDown navigate rows, ArrowLeft / ArrowRight navigate columns. Home / End jump within row; Ctrl+Home / Ctrl+End jump to grid boundaries; Page Up / Page Down move by viewport-row-count.

    Without 2D arrow-key navigation, the grid pattern degrades to a Table with misleading role. Keyboard users cannot navigate cell-by-cell; the canonical affordance disappears.

  3. APGAPG: Grid pattern editable cells interaction

    Editable variant supports F2 / Enter to enter edit-mode, Escape to cancel (revert + exit), Enter to commit (commit + exit), Tab to commit-and- advance. The three exits are the canonical edit-mode contract.

    Without the three-exit contract, users get trapped in the cell-editor (no Escape) or commit changes accidentally (no Tab-to-commit-and-advance) or cannot enter edit-mode at all (no F2 / Enter handler). The canonical edit- lifecycle is the difference between usable and unusable editable grids.

  4. APGWAI-ARIA grid role and required children

    Cells inside `role="grid"` carry `role="gridcell"` (data cells), `role="columnheader"` (column headers), or `role="rowheader"` (row headers). The role hierarchy is consistent — grid contains rows, rows contain cells with the canonical cell roles.

    Without the role hierarchy, AT announces inconsistent semantics — root as grid but cells as plain table cells. SR users hear "grid, 5 rows, 7 columns" on entry but "cell, 12" without the gridcell role on navigation. The role mismatch breaks programmatic relationships AT relies on.

  5. APGAPG: Grid pattern virtualization guidance

    Virtualized grids declare `aria-rowcount` and `aria-colcount` on the root and `aria-rowindex` / `aria-colindex` per rendered cell. The virtualizer keeps these in sync as it recycles DOM nodes.

    Without the explicit indices, AT users perceive the dataset as the rendered count (50 of 10,000); Page-Down navigation becomes unreliable as the virtualizer recycles nodes. The virtualization-aware ARIA attributes are the canonical bridge between the rendered subset and the logical structure.

Vocabulary drift

WAI-ARIA
role=grid + role=gridcell + role=row
Canonical ARIA pattern. WAI-ARIA Authoring Practices Guide documents the full keyboard model including roving- tabindex, 2D arrow navigation, F2 / Enter / Escape edit-mode lifecycle. The canonical anatomy mirrors APG directly.
HTML
<table role="grid">
Native HTML `<table>` plus explicit `role="grid"` is the canonical hybrid — retains the structural HTML semantics while signaling the interactive keyboard model. `<div role="grid">` is the fallback for non-tabular layouts.
AG Grid
AgGridReact / AgGridAngular / AgGridVue
AG Grid is the most common production grid library across React / Angular / Vue. Implements roving-tabindex, virtualization, edit-mode lifecycle, cell renderers, and editor renderers out of the box. Naming-only divergence from canonical (`columnDefs` / `rowData` props; `onCellEditingStarted` / `onCellEditingStopped` events).
React Aria
useGrid + useGridCell + useGridRow
React Aria ships headless hooks for the Grid pattern — consumers compose the DOM and visual treatment, the hooks wire the ARIA attributes plus roving-tabindex. The grid keyboard model is implemented inside the hooks; consumers do not re-implement it.
TanStack Table
createColumnHelper + useReactTable (read-only)
TanStack Table provides headless table logic (sorting, filtering, virtualization) but does not implement the grid keyboard model. Pair TanStack Table with React Aria's grid hooks for canonical Grid-pattern compliance, or escalate to AG Grid for full grid-pattern feature coverage.
Material 3
— (no formal Grid pattern component)
Material 3 spec does not include the Grid pattern. Material UI ships `DataGrid` / `DataGridPro` (separate library, paid tier) implementing the canonical pattern. Angular Material's `MatTable` does not ship the grid keyboard model.
Carbon
DataTable (with inline-edit pattern)
Carbon ships `DataTable` covering the Table pattern; for editable grids, Carbon documents an inline-edit pattern but does not ship a dedicated Grid component. Production Carbon applications tend to escalate to AG Grid for editable use cases.
Glide Data Grid
Canvas-based DataGrid (React)
Canvas-based grid implementation (renders cells via `<canvas>` rather than DOM). High performance for very large grids (100k+ rows) but requires explicit canvas-accessibility-tree wiring for AT support. Canonical WAI-ARIA grid-pattern compliance is implementation-specific — Glide documents an `accessibility` prop that wires `aria-` attributes for the focused cell.
Both

Common mistakes

Blocker

#grid-no-roving-tabindex

Every cell is `tabindex="0"` (or no cells are)

Problem

The grid renders without roving-tabindex — either every cell carries `tabindex="0"` (creating N×M tab stops and breaking the single-tab-into-grid contract) or no cells carry `tabindex="0"` (cells unreachable via keyboard). Both patterns violate the APG grid keyboard model. Tab from outside should enter the grid at exactly one active cell; arrow keys should move focus between cells without changing the tab-stop count.

Fix

Wire roving-tabindex: only the active cell has `tabindex="0"`; all other cells have `tabindex="-1"`. On arrow-key navigation, shift the `tabindex="0"` from the leaving cell to the entering cell. Tab from outside enters the active cell; Tab inside the grid exits to the next document focusable. Most grid libraries (React Aria, AG Grid, Glide DataGrid) implement this correctly out of the box.

Blocker

#grid-arrow-no-cell-focus

Arrow keys do not move focus between cells

Problem

The grid implements no arrow-key handler (or implements scroll-only behaviour like `Table`). Keyboard users cannot navigate cell-by-cell — Tab through the grid jumps between rows or exits the grid entirely; arrow keys do nothing. The whole point of the grid pattern is 2D arrow-navigation; without it, the surface is a Table with misleading `role="grid"`.

Fix

Implement arrow-key handlers — ArrowUp / ArrowDown move focus to the cell in the adjacent row, same column; ArrowLeft / ArrowRight move to adjacent column, same row. Home / End jump to the first / last cell in the row; Ctrl+Home / Ctrl+End jump to the first / last cell of the entire grid. Page Up / Page Down move by a viewport's worth of rows. The handlers shift the roving-tabindex along with the focus.

Major

#grid-edit-mode-no-escape

Escape does not cancel edit-mode

Problem

In editable variant, the cell-editor enters on F2 / Enter but Escape does not return focus to the cell with the original value preserved. Users get trapped in the editor — Escape is the canonical "I changed my mind, revert" gesture and its absence forces users to either commit a tentative edit or hunt for an alternative cancel path.

Fix

Implement Escape inside the cell-editor: revert the cell's value to its pre-edit state, unmount the editor, return focus to the cell, exit edit-mode. Enter inside the editor commits the new value and exits; Tab inside the editor commits AND moves to the next cell. The three exits (Enter commit, Tab commit-and-advance, Escape cancel) are the canonical edit-mode contract.

Major

#grid-cell-not-gridcell-role

Cells use `<td>` without `role="gridcell"` when root is `role="grid"`

Problem

The root element carries `role="grid"` but the cells are bare `<td>` elements without `role="gridcell"`. AT announces the root as a grid but the cells inside as plain table cells — the role hierarchy is broken. SR users hear inconsistent semantics on grid entry vs cell navigation.

Fix

When the root is `role="grid"` (or `<table role="grid">`), the cells inside MUST carry `role="gridcell"` (data cells), `role="columnheader"` (column header cells), or `role="rowheader"` (row header cells). For native `<th>` and `<td>` elements, add `role="columnheader"` / `role="gridcell"` explicitly — the implicit table semantics do not propagate through the explicit `role="grid"` override.

Major

#grid-no-aria-rowcount-virtualized

Virtualized grid omits `aria-rowcount` and `aria-rowindex`

Problem

The grid renders only viewport-visible rows plus a buffer (50 of 10,000); without `aria-rowcount` on the root and `aria-rowindex` per row, AT reports the rendered count as the total. Page-Down navigation becomes unreliable because the virtualizer recycles DOM nodes between renders. AT users perceive the dataset as smaller than reality.

Fix

On the root set `aria-rowcount="10000"` (true total) and `aria-colcount` similarly. On each rendered row set `aria-rowindex` to the 1-based true index (not the rendered offset); on each cell set `aria-colindex` to the 1-based true column index. The virtualizer keeps these in sync as it recycles rows; AT reports the real dataset size.

Minor

#grid-when-table-suffices

Grid pattern used when Table would work

Problem

The component ships with `role="grid"` and roving-tabindex but the cells are not themselves interactive — content is read- only, no edit-mode, no cell-level selection. The added keyboard-model complexity (roving-tabindex bookkeeping, arrow-key handlers, Page-Up / Page-Down logic) buys nothing. Authors paid the cost without gaining the affordance.

Fix

Use `Table` for read-only data display. The cells can still contain interactive descendants (links, buttons, checkboxes) reachable via standard Tab order. Reserve `Grid` for surfaces where cells themselves are interactive units — editable cells, cell-by-cell selection, focus-cell-driven drilldown. The escalation cost should buy a real keyboard-model improvement.