Grid (interactive data grid)
An interactive tabular widget with two-dimensional arrow-key cell navigation (role="grid"). Use only when cells are interactive — read-only data belongs in a Table.
Last verified against WCAG 2.2 and WAI-ARIA APG 1.2 on 2026-07-08.
Implementation
Grid or Table?
Reach for the grid pattern only when cells are interactive — editable values, selectable cells, or cells that contain their own controls — so the widget needs to own two-dimensional arrow-key navigation. For static data the user only reads, use a Table instead: a real <table> keeps the screen reader's own table-navigation keys working and needs none of this keyboard code. A grid has no native HTML element, so unlike a table there is no simpler markup to fall back to — every role and key is on you.
Tab once to enter the grid, then use the ↑ ↓ ← → arrow keys (plus Home / End, Ctrl+Home / Ctrl+End) to move between cells.
// One cell owns tabindex=0; every other cell is tabindex=-1 (roving tabindex),
// so the whole grid is a SINGLE Tab stop and arrow keys drive navigation.
const [active, setActive] = useState({ row: 0, col: 0 });
function onKeyDown(e, row, col) {
let r = row, c = col;
switch (e.key) {
case "ArrowRight": c = Math.min(col + 1, COL_COUNT - 1); break;
case "ArrowLeft": c = Math.max(col - 1, 0); break;
case "ArrowDown": r = Math.min(row + 1, ROW_COUNT - 1); break;
case "ArrowUp": r = Math.max(row - 1, 0); break;
case "Home": c = 0; if (e.ctrlKey) r = 0; break; // start of row / grid
case "End": c = COL_COUNT - 1; if (e.ctrlKey) r = ROW_COUNT - 1; break;
default: return; // Tab, typing, etc. behave normally
}
e.preventDefault();
setActive({ row: r, col: c });
cellRefs.current[r][c].focus(); // move DOM focus to match the roving state
}
<div role="grid" aria-label="Quarterly revenue by region" aria-readonly="true">
<div role="row">
<div role="columnheader">Region</div>
<div role="columnheader">Q1</div>
{/* … */}
</div>
<div role="row">
<div role="rowheader" tabIndex={isActive ? 0 : -1} onKeyDown={…}>North America</div>
<div role="gridcell" tabIndex={isActive ? 0 : -1} onKeyDown={…}>820</div>
{/* … */}
</div>
</div>Live demo
Tab once to enter the grid, then use the ↑ ↓ ← → arrow keys (plus Home / End, Ctrl+Home / Ctrl+End) to move between cells.
Required roles, states & properties
| Element | Attribute | Why |
|---|---|---|
| Grid container | role="grid" | Declares an interactive composite widget so assistive tech switches out of document-reading mode and lets the app's own arrow-key handling drive cell navigation. This is the one thing that distinguishes a grid from a static table. |
| Grid container | aria-label / aria-labelledby | Gives the whole widget an accessible name (a grid has no <caption>), announced when focus first enters it. |
| Grid container | aria-readonly="true" | Declares that cells display but cannot be edited. Omit it (or set false) for an editable grid where cells accept input. |
| Each row | role="row" | Rebuilds the row grouping that div markup loses, so a cell is announced with its row context. |
| Header cells | role="columnheader" / role="rowheader" | Associates each data cell with its column and row labels, so navigating to a cell announces e.g. "North America, Q3, 880" rather than a bare number. |
| Data cells | role="gridcell" | Marks the individual navigable cells that make up the grid's two-dimensional structure. |
| Cells (roving tabindex) | tabindex="0" on the active cell, tabindex="-1" on all others | Makes the grid a single stop in the page Tab order; the arrow keys then move the single tabindex=0 among cells and set DOM focus to match, instead of exposing dozens of separate tab stops. |
Keyboard interaction model
| Key | Behavior |
|---|---|
| Tab / Shift+Tab | Moves into and out of the grid as a whole — the entire grid is a single tab stop. Focus lands on whichever cell was last active (initially the first cell). |
| Right / Left Arrow | Moves focus one cell right / left within the current row, stopping at the row's edge (no wrap). |
| Down / Up Arrow | Moves focus one cell down / up within the current column, stopping at the grid's edge. |
| Home / End | Moves focus to the first / last cell in the current row. |
| Ctrl + Home / Ctrl + End | Moves focus to the first cell of the first row / the last cell of the last row. |
This is the core of the pattern: the grid takes a single Tab stop, and the arrow keys (plus Home/End and their Ctrl variants) move a roving tabindex=0 among the cells. A plain Table deliberately does not do this — arrow keys there belong to the screen reader, not the app.
Focus management rules
- Exactly one cell has
tabindex=0at any moment; every other cell istabindex=-1. This is what makes the whole grid a single tab stop. - Every arrow/Home/End key press updates both the roving tabindex and actual DOM focus in the same handler, so they never drift apart.
- Focus stops at the grid's edges — arrow keys do not wrap around to the opposite side.
- When focus leaves and later returns via Tab, it lands on the last-active cell, not a reset to the first cell.
WCAG 2.2 success criteria mapping
| SC | Name | Level | Why it applies |
|---|---|---|---|
| 1.3.1 | Info and Relationships | A | Structure and relationships conveyed visually are also programmatically determinable. |
| 2.1.1 | Keyboard | A | All functionality is operable through a keyboard interface with no specific timing. |
| 2.4.3 | Focus Order | A | Focusable components receive focus in an order that preserves meaning and operability. |
| 2.4.7 | Focus Visible | AA | Any keyboard-operable UI has a visible focus indicator. |
| 4.1.2 | Name, Role, Value | A | For all UI components, name, role, and value are programmatically determinable; states and changes are announced. |