Data Display
Grid
AvailableAn interactive table (data grid) where individual cells are keyboard-navigable.
Specification updated
- Editorial#3356(opens in a new tab)Grid: Fix arrow key behavior description
- Enhancement#3387(opens in a new tab)Keyboard Interface Practice: Clarify guidance on focusability of disabled controls
- Enhancement#3311(opens in a new tab)Keyboard Interface Practice: Add note on focus management for pointer activation
What Is a Grid (Data Grid)?
A grid is an interactive UI that lets users navigate tabular data freely using arrow keys. Like a spreadsheet, you can move focus from cell to cell to interact with the data.
This kind of grid is different from the CSS layout grid. Also, if you just need to display data in a table, use a plain <table> — there's no need for a grid. Only use a grid when interaction is required, such as when you need cell-level focus movement or cells contain interactive elements.
Why Does Accessibility Matter?
The more interactive a table becomes, the more people get locked out when it's built incorrectly.
- Keyboard-only users: If cells only have
onclick, they can't be reached by Tab or arrow keys — making the table completely inoperable. - Screen reader users: Without
role="grid", the table isn't recognized as a grid — the row/column structure and current cell position aren't communicated.
The solution is to declare the structure with role="grid" and move focus with arrow keys (roving tabindex). The entire grid becomes a single tab stop, and navigation within it is handled by arrow keys.
Live Demo (Recommended Implementation)
The employee directory below is an interactive grid built following the APG.Without using a mouse, press Tab to enter the grid, then use arrow keys to move between cells.
Employee Directory
Try it: Tab to enter the grid → ↑ ↓ ← → to move between cells → Home / End for the first/last cell in a row, Ctrl + Home / Ctrl + End for the first/last cell in the grid.
Tip
Turn on a screen reader (on macOS, press ⌘+F5 for VoiceOver) and enter a cell. You'll hear something like "grid, 4 columns, 5 rows" describing the structure, and as you move, "Name column, Hanako Sato" announcingthe column header and cell value. This is the effect of role="grid" and column headers.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| Tab | Enter / exit the grid (the grid is a single tab stop) | Required |
| → / ← | Move focus to the right / left cell | Required |
| ↓ / ↑ | Move focus to the cell below / above | Required |
| Home / End | Move to the first / last cell in the current row | Required |
| Ctrl + Home / Ctrl + End | Move to the first / last cell in the grid | Recommended |
Note
Do not use Tab for cell navigation within the grid.Tab should be reserved for leaving the grid, while cell navigation is handled by arrow keys. This is the key difference from a native table.
Required WAI-ARIA Roles and Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Outer wrapper | role="grid" | Declares the element as an interactive grid. |
| Outer wrapper | aria-label / aria-labelledby | Provides an accessible name for the grid (describes what the table represents). |
| Each row | role="row" | Identifies a row grouping. |
| Header cells | role="columnheader" | Column header. Read in association with cells in the same column. |
| Data cells | role="gridcell" | An individual cell. |
| Focusable cell | tabindex="0" (one only) / "-1" (all others) | Roving tabindex. Makes the grid a single tab stop; swap tabindex values as focus moves. |
Implementation: Recommended Pattern (Good)
Good / Recommended
Declare the structure with role="grid", and keep only one cell at tabindex="0"at a time, moving focus with arrow keys (roving tabindex).
Markup:
<div role="grid" aria-labelledby="grid-cap">
<div role="row">
<span role="columnheader" tabindex="-1">Employee ID</span>
<span role="columnheader" tabindex="-1">Name</span>
<span role="columnheader" tabindex="-1">Department</span>
<span role="columnheader" tabindex="-1">Extension</span>
</div>
<div role="row">
<!-- Only the first cell in the entire grid has tabindex="0" (the rest are -1) -->
<span role="gridcell" tabindex="0">E-001</span>
<span role="gridcell" tabindex="-1">Hanako Sato</span>
<span role="gridcell" tabindex="-1">Sales</span>
<span role="gridcell" tabindex="-1">1201</span>
</div>
<!-- Subsequent rows also use role="row" + role="gridcell" tabindex="-1" -->
</div>Keyboard interaction script (focus movement and tabindex management):
const grid = document.getElementById('grid-good');
if (grid) {
// Represent cells as a 2D array (array of cells per row)
const rows = Array.from(grid.querySelectorAll<HTMLElement>('[role="row"]'));
const cells = rows.map((row) =>
Array.from(row.querySelectorAll<HTMLElement>('[role="columnheader"],[role="gridcell"]'))
);
let r = 0; // Current row
let c = 0; // Current column
// Set tabindex="0" only on the current cell, -1 on all others (single tab stop)
function focusCell(nextR: number, nextC: number) {
const maxR = cells.length - 1;
r = Math.max(0, Math.min(nextR, maxR));
const maxC = cells[r].length - 1;
c = Math.max(0, Math.min(nextC, maxC));
cells.flat().forEach((cell) => cell.setAttribute('tabindex', '-1'));
const target = cells[r][c];
target.setAttribute('tabindex', '0');
target.focus();
}
grid.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowRight': focusCell(r, c + 1); break;
case 'ArrowLeft': focusCell(r, c - 1); break;
case 'ArrowDown': focusCell(r + 1, c); break;
case 'ArrowUp': focusCell(r - 1, c); break;
case 'Home':
if (e.ctrlKey) focusCell(0, 0); // First cell
else focusCell(r, 0); // First cell in row
break;
case 'End':
if (e.ctrlKey) focusCell(cells.length - 1, // Last cell
cells[cells.length - 1].length - 1);
else focusCell(r, cells[r].length - 1); // Last cell in row
break;
default: return; // Let the browser handle other keys
}
e.preventDefault();
});
// Sync current position when a cell is clicked
cells.forEach((row, ri) =>
row.forEach((cell, ci) => {
cell.addEventListener('click', () => focusCell(ri, ci));
})
);
}Note
If cells contain buttons or links, first ensure arrow key navigation between cells works, then consider adding an "edit mode" entered via Enter or F2 to interact with elements inside the cell. Start by making the one cell = one focus target basic pattern work reliably.
Anti-pattern (Bad)
Below is a table where cells only have onclick — it looks interactive but isn't.You can click cells with a mouse, but keyboard interaction is completely impossible.Try Tab → arrow keys and compare the experience to the demo above.
| Employee ID | Name | Department | Extension |
|---|---|---|---|
| E-001 | Hanako Sato | Sales | 1201 |
| E-002 | Taro Suzuki | Engineering | 1310 |
| E-003 | Misaki Takahashi | Human Resources | 1105 |
| E-004 | Ken Tanaka | Engineering | 1322 |
Try it: Tab doesn't focus any cell, and arrow keys don't work. A screen reader won't recognize it as a grid, and row/column/position information isn't communicated.
<!-- ❌ Anti-pattern -->
<!-- Looks like a table but doesn't function as an interactive grid -->
<table>
<tr>
<th>Employee ID</th><th>Name</th><th>Department</th><th>Extension</th>
</tr>
<tr>
<!-- onclick only. No role, tabindex, or key handling -->
<td onclick="select(this)">E-001</td>
<td onclick="select(this)">Hanako Sato</td>
<td onclick="select(this)">Sales</td>
<td onclick="select(this)">1201</td>
</tr>
</table>Bad / Avoid
Problems with this implementation:
- No keyboard access — Cells have no
tabindexor key handling, so arrow keys don't work. - Not recognized as a grid — Without
role="grid", assistive technology treats it as a plain table. - Not a single tab stop — Roving tabindex pattern is not implemented.
- Position not communicated — Current row, column, and selection state are not announced.
Tip
If your table doesn't need click or keyboard interaction, don't force it into a grid. A native <table> with <th scope> is perfectly fine. Only introduce role="grid" and roving tabindex when cell-level interaction is truly needed.
Implementation Checklist
- The outer wrapper has role="grid" and a name (aria-label / aria-labelledby)
- Rows use role="row", headers use role="columnheader", and cells use role="gridcell"
- Exactly one cell has tabindex="0" and all others have "-1" (roving tabindex)
- Arrow keys move between cells, and Home / End move to the edges of the row
- (Recommended) Ctrl+Home / End moves to the edges of the grid
- Tab exits the grid rather than moving between cells
- All operations are keyboard-accessible and focus is visible (focus ring is not hidden)
Field Notes — Where practice diverges from the APG
Avoid the grid pattern outside spreadsheet-like editing — prefer a plain <table>
- The APG says
- Interactive tables needing cell-level selection and two-dimensional arrow-key navigation should use the grid / treegrid pattern, with roving tabindex making the whole table one tab stop.
- What practice does
- Adrian Roselli says to ignore the grid role unless you are recreating spreadsheet-class editing — a table that merely contains links or buttons should stay a plain <table>. Sarah Higley frames the decision as interaction-first vs. information-first, showing concrete cases where visually grid-like UIs should not become ARIA grids.
- Why
- Declaring role="grid" obligates you to a full extra keyboard layer — 2D arrow navigation, managed tab stops — and incomplete implementations ship constantly. User testing surfaced confusion at unexpected arrow-key behavior and mismatches between responsive layouts and arrow-navigation column semantics. Upgrading a table to a grid just because rows are clickable is over-engineering; a plain table also announces column headers more naturally. Both practitioners reach this conclusion from testing, not theory.
Adopted by:Adrian Roselli、Sarah Higley
Sources:Adrian Roselli — ARIA Grid As an Anti-Pattern(opens in a new tab)、Sarah Higley — Grids Part 1: To grid or not to grid(opens in a new tab)
React Aria's Calendar deliberately prunes grid navigation
- The APG says
- The grid pattern makes the entire grid — including row and column headers — navigable with arrow keys.
- What practice does
- React Aria's Calendar follows the grid pattern but skips the weekday header row in screen reader navigation, and adds a visually hidden "next" button so users can advance months without trekking back to the first cell.
- Why
- On mobile screen readers, where users step through elements sequentially, walking the full APG grid proved verbose and inefficient in testing. Skipping headers and adding the hidden button prioritize real traversal efficiency over pattern completeness. For the same reason, date entry uses individually focusable segments per unit rather than the free-form text field the APG assumes.
Adopted by:React Aria、React Spectrum
Sources:React Aria Blog — Date and Time Pickers for All(opens in a new tab)
Source (English):Grid Pattern — W3C APG(opens in a new tab)