Accessible 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 is tabindex="-1" (roving
tabindex), so the whole grid is a SINGLE Tab stop and arrow keys
drive navigation. aria-readonly since this grid isn't editable. -->
<div role="grid" aria-label="Quarterly revenue by region" aria-readonly="true">
<div role="row">
<div role="columnheader" tabindex="0">Region</div>
<div role="columnheader" tabindex="-1">Q1</div>
<div role="columnheader" tabindex="-1">Q2</div>
</div>
<div role="row">
<div role="rowheader" tabindex="-1">North America</div>
<div role="gridcell" tabindex="-1">820</div>
<div role="gridcell" tabindex="-1">910</div>
</div>
<div role="row">
<div role="rowheader" tabindex="-1">Europe</div>
<div role="gridcell" tabindex="-1">640</div>
<div role="gridcell" tabindex="-1">700</div>
</div>
</div>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. |