Accessible Data Grid Guide
A data grid lets a user navigate and operate a matrix of cells — a spreadsheet, an editable table, a dense grid of interactive controls. This guide covers the role="grid" / row / gridcell structure built on a real <table>, two-dimensional arrow-key navigation, roving tabindex, the two focus modes (navigation vs actionable), editable cells, selection, aria-rowcount / aria-colcount / aria-rowindex / aria-colindex for virtualized grids, and React — with copy-ready code mapped to WCAG 2.2, and the one question to ask before you build one at all.
The Widget Defined by Two-Dimensional Navigation
A data grid is the control you reach for when a table stops being something the user reads and becomes something the user operates — a spreadsheet they edit cell by cell, a data grid with sortable columns and inline controls, a dense matrix they walk with the arrow keys. On screen it can look exactly like an ordinary table. What makes it a grid to assistive technology is a specific bundle of semantics and a specific keyboard model: one Tab stop, and arrow keys that move a single roving focus in two dimensions — left and right along a row, up and down a column.
The grid has an ARIA pattern of its own — the WAI-ARIA Authoring Practices Grid pattern — built from role="grid", role="row", role="gridcell", and the columnheader / rowheader roles, with aria-selected for what is chosen, aria-readonly where cells cannot be edited, and aria-rowcount, aria-colcount, aria-rowindex and aria-colindex to say where each cell sits in the full data set. Unlike a slider or a switch, there is no single native element that gives you a grid — but, crucially, you should still build it on top of a real <table>, so the header-and-cell relationships come for free and you add only the interaction layer.
Before anything else: most data tables should not be grids. If the user only reads the data — a pricing table, a comparison, a report — use a plain semantic <table> with <th scope> headers. Reserve role="grid" for when the user navigates or edits cells with the keyboard. Section 1 is the decision.
This guide walks that decision first, then the anatomy, the DOM structure on a real table, the full two-dimensional keyboard model, the roving-tabindex focus engine, the two focus modes (navigation vs actionable) that are the heart of the pattern, editable cells, selection, the count and index properties that make a virtualized grid usable, the treegrid variant, a React approach, and how to test the result.
The WCAG 2.2 Criteria a Data Grid Must Satisfy
| Criterion | Level | What the grid must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | Row and column structure, each cell's headers, its position in the full grid, and the selected state are exposed programmatically — not implied by visual layout alone. |
| 2.1.1 Keyboard | A | Every action — move in all four directions, jump to row and grid ends, enter and edit cells, select — works from the keyboard alone. |
| 2.1.2 No Keyboard Trap | A | Escape always returns from a cell's actionable or edit mode to navigation mode, and Tab moves focus out of the grid entirely — the arrows navigate, they never trap. |
| 2.4.3 Focus Order | A | Roving tabindex keeps the grid a single Tab stop, and focus moves in a predictable order that matches the visual grid. |
| 1.4.1 Use of Color | A | Which cell or row is selected is shown by more than colour — aria-selected plus a checkbox, checkmark, or outline, not a background tint alone. |
| 2.4.7 Focus Visible | AA | The focused cell shows a clearly visible indicator that is distinct from the selected-cell styling. |
| 1.4.11 Non-text Contrast | AA | The focus indicator, the selection indicator, and any control glyphs inside cells each reach at least 3:1 against what they sit on. |
| 2.5.8 Target Size (Minimum) | AA | Any clickable target inside a cell — a sort button, a checkbox, an edit control — is at least 24 by 24 CSS pixels. |
| 4.1.2 Name, Role, Value | A | Each cell exposes the gridcell role, its content, its headers, and its state — selected, read-only, and its row and column position. |
The two criteria grids fail most often are 2.1.1 Keyboard — because the full two-dimensional model, with row and grid ends and the two focus modes, is a lot to wire and easy to leave half-built — and 2.1.2 No Keyboard Trap, when a user steps into a cell full of controls and finds there is no Escape back out.
1. Grid, Table, or Treegrid?
A grid is one of the heaviest patterns in the ARIA toolkit — you own the roving focus, the two-dimensional keyboard model, the two focus modes, and the count and index properties. Reach for it only when the user genuinely operates on the cells. For many things that look like a grid, a plain table is more robust and far less code. Match the control to the job before you build.
Plain table
The user reads the data — a pricing table, a comparison, a report. A native <table> with <th scope> headers. No roles, no JavaScript. See the interactive tables demo.
Data grid
The user navigates or edits cells with the arrow keys — a spreadsheet, an editable data grid, a matrix of controls in one Tab stop. role="grid". This is what the guide covers.
Treegrid
A grid whose rows expand and collapse — a message list grouped into threads, a hierarchical report. role="treegrid": the grid model plus the tree expand logic. Section 10.
The single clearest tell: does the user read the data, or navigate and operate the cells? If they read it, a native table is battle-tested, works without JavaScript, and gives screen reader users the powerful table-reading commands they already know. A grid earns its cost only when arrow-key cell navigation or in-place editing is the interaction: a spreadsheet, a data grid, a calendar of selectable days. When in doubt, start with the table and upgrade only if the interaction truly demands it.
2. Anatomy: Roles, States, and Properties
A grid carries the full structure of a table plus the state a widget needs. The good news is that a real <table> supplies most of the roles implicitly — <tr> is a row, <thead> and <tbody> are rowgroups, and <th scope> is a columnheader or rowheader — so you mostly add the interaction attributes. Here is the full set and what each part is for.
| Element / concept | Attribute | Purpose |
|---|---|---|
| The grid container | role="grid" + aria-label / aria-labelledby | Best applied to a real <table> element. A single Tab stop that needs an accessible name. Add aria-multiselectable="true" when more than one cell or row can be selected, and aria-readonly="true" when no cell is editable. |
| Each row | role="row" | One per row of cells (a <tr> already has this role implicitly). Carries aria-selected for row selection and aria-rowindex when the grid is virtualized. |
| Row groups | role="rowgroup" | Wraps the header rows and the body rows (<thead> and <tbody> provide this implicitly). Groups rows so structure is exposed to assistive technology. |
| Column header | role="columnheader" | The header cell at the top of a column (a <th scope="col"> is this role). Names the column for every cell beneath it. |
| Row header | role="rowheader" | The header cell that labels a row, usually the first cell (a <th scope="row"> is this role). Names the row for the cells across it. |
| Data cell | role="gridcell" | Every operable data cell (a <td> in a role="grid" table takes this role). Holds the roving tabindex, aria-selected, aria-readonly, and — when virtualized — aria-colindex. |
| Roving focus | tabindex="0" / tabindex="-1" | Exactly one cell (or one widget inside a cell) has tabindex="0"; every other has tabindex="-1". The arrow keys move the 0 across the grid. |
| Total size | aria-rowcount / aria-colcount | On the grid. The real total number of rows and columns when the DOM does not contain them all (virtualized grids). Use -1 when a total is genuinely unknown. |
| Cell position | aria-rowindex / aria-colindex | The 1-based position of a row and a cell within the full grid, not the DOM. Required once rows are virtualized so the user hears "row 4,500 of 10,000". |
One important constraint: a grid row's cells must be direct children of the row, and a grid must not contain content that is not part of a row and cell — everything inside role="grid" lives in a row, and every operable thing lives in a gridcell or a header cell. For how each role and property surfaces to assistive technology, see the ARIA roles & attributes reference.
3. The HTML Structure — Build on a Real Table
Start from a semantic <table> and add role="grid". The <tr>, <thead>, <tbody>, and <th scope> elements already carry the right roles, so you inherit the header-and-cell relationships for free and layer the interaction on top. Exactly one cell — here the first data cell — starts at tabindex="0"; every other operable cell is tabindex="-1".
<table role="grid" aria-label="Team members" aria-readonly="true">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<!-- The one cell in the Tab order to start with. -->
<td tabindex="0">Alice Johnson</td>
<td tabindex="-1">Frontend Developer</td>
<td tabindex="-1">Active</td>
</tr>
<tr>
<td tabindex="-1">Bob Smith</td>
<td tabindex="-1">Product Manager</td>
<td tabindex="-1">Busy</td>
</tr>
</tbody>
</table>Building on a real <table> is the single biggest favour you can do yourself. Because <th scope="col"> and <td> already establish the header-to-cell relationships, a screen reader can announce the column header with each cell without any extra ARIA. If you build the grid from <div>s instead, you have to recreate every one of those relationships by hand with role attributes — more code and more to get wrong.
Put the roving tabindex on the cell itself when the cell holds plain text or a single widget. When a cell holds one interactive control — a link or a button — you can put the tabindex="0"/"-1" on that control instead of the cell, so activating it needs no extra step. The next sections cover what happens when a cell holds more than one control.
4. The Two-Dimensional Keyboard Model
This is the contract that turns a table into a grid, and there is no native element to implement it for you — every key here is yours to wire up. A grid that responds only to Tab is not a grid; it is a table with the reading commands switched off.
| Key | Expected behavior |
|---|---|
| Right Arrow | Moves focus one cell to the right, staying in the same row. Stops at the last cell of the row — it does not wrap to the next row. |
| Left Arrow | Moves focus one cell to the left, staying in the same row. |
| Down Arrow | Moves focus one cell down, staying in the same column. |
| Up Arrow | Moves focus one cell up, staying in the same column. |
| Home | Moves focus to the first cell in the current row. |
| End | Moves focus to the last cell in the current row. |
| Ctrl + Home | Moves focus to the first cell of the first row. |
| Ctrl + End | Moves focus to the last cell of the last row. |
| Page Down / Page Up | Optional: moves focus down or up by a set number of rows (an author-defined page), for moving quickly through a tall grid. |
| Enter or F2 | On a cell with interactive content: enters actionable mode so Tab can move between the controls inside the cell. In an editable grid: opens the cell's editor. |
| Escape | Returns from actionable or edit mode to navigation mode, focus back on the cell. Cancels an in-progress edit. This is what keeps the grid from trapping focus. |
| Space | In a selectable grid, toggles selection of the focused cell or row (aria-selected). Shift+Arrow extends a selection; Ctrl+A selects all. |
| Tab / Shift + Tab | Moves focus into and out of the whole grid. The grid is a single Tab stop; inside actionable mode, Tab moves between a cell's own controls. |
The arrow keys stop at the edges — Right Arrow on the last cell of a row does nothing, it does not wrap around to the next row. For the wider keyboard contract every custom widget owes, and the roving-tabindex technique the next section builds on, see the keyboard accessibility guide and the focus management guide.
5. Roving Tabindex in Two Dimensions
A grid is a composite widget: it should be a single stop in the Tab order, and once focus is inside, the arrow keys move between cells. The technique that achieves this is roving tabindex. Exactly one cell carries tabindex="0" and is in the Tab sequence; every other cell carries tabindex="-1", which keeps it focusable by script but out of the Tab order. When the user arrows to a new cell, you move the 0 — the only difference from a one-dimensional widget is that you track a row and a column, not a single index.
const grid = document.querySelector('[role="grid"]')
const rows = [...grid.querySelectorAll('tr')]
// The current cell, as [rowIndex, colIndex].
let active = [1, 0] // skip the header row
function cellAt([r, c]) {
return rows[r]?.querySelectorAll('th, td')[c]
}
// Move the roving tabindex="0" from the old cell to the new one.
function focusCell(next) {
const target = cellAt(next)
if (!target) return // off the edge: ignore
cellAt(active)?.setAttribute("tabindex", "-1") // old cell out of Tab order
target.setAttribute("tabindex", "0") // new cell into it
target.focus()
active = next
}
grid.addEventListener("keydown", (e) => {
const [r, c] = active
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": focusCell([r, 0]); break
case "End": focusCell([r, rows[r].cells.length - 1]); break
default: return // let other keys through
}
e.preventDefault() // <- stop the arrows scrolling the page
})The number-one grid bug is giving every cell tabindex="0". It looks like it works with a mouse, but now a keyboard user has to Tab through every cell to get past the widget, and the arrow-key model never runs. A grid with a thousand cells becomes a thousand Tab stops. One 0, the rest -1 — always. And note the closing e.preventDefault(): without it the arrows move the cell and scroll the page.
There is a second valid model, aria-activedescendant: DOM focus stays on the role="grid" container and an aria-activedescendant attribute on it points at the idof the active cell, which you move as the user arrows. It can be simpler with virtualised data, but it puts the burden of a visible “active cell” style entirely on your CSS. Roving tabindex is the more common choice for grids and the one this guide uses. See focus management for both models side by side.
6. The Two Focus Modes — Navigation vs Actionable
This is the behaviour that makes a grid genuinely different from a tree or a listbox, and it is where most custom grids break. The arrow keys can only mean one thing at a time. When a cell holds several controls — or a widget that needs the arrows itself — you cannot let Right Arrow both move to the next cell and operate the widget. The grid solves this with two modes.
Navigation mode
- The default. Arrow keys move focus from cell to cell.
- Each cell is a single stop, even if it contains controls.
- Enough on its own when a cell is empty or holds exactly one focusable widget.
Actionable mode
- Enter / F2 steps into the focused cell.
- Tab / Shift+Tab move between the cell's own controls; the widget's keys work normally.
- Escape steps back out to navigation mode, focus on the cell.
The rule of thumb is about how many focusable things a cell holds. If a cell holds nothing interactive or a single widget, stay in navigation mode — put the roving tabindex on the cell or on that one widget and you are done. If a cell holds two or more controls, or a control that itself needs the arrow keys, use actionable mode so the user can reach each control without the arrows being hijacked.
let mode = "navigation"
grid.addEventListener("keydown", (e) => {
const cell = e.target.closest("th, td")
if (!cell) return
// --- Actionable mode: Tab moves between controls, Escape leaves. ---
if (mode === "actionable") {
if (e.key === "Escape") {
mode = "navigation"
cell.setAttribute("tabindex", "0")
cell.focus() // return focus to the cell itself
e.preventDefault()
}
return // let Tab and the widget's own keys through
}
// --- Navigation mode ---
switch (e.key) {
case "ArrowRight": case "ArrowLeft":
case "ArrowUp": case "ArrowDown":
case "Home": case "End":
moveFocus(e.key, cell) // roving tabindex from section 5
e.preventDefault()
break
case "Enter": case "F2": {
// Step into a cell that has focusable content.
const widget = cell.querySelector("a, button, input, select, textarea")
if (widget) {
mode = "actionable"
widget.focus()
e.preventDefault()
}
break
}
}
})Escape is not optional. The moment you let a user step into a cell, you must give them a way back out. If Enter enters actionable mode but nothing returns to navigation mode, Tab just cycles the cell's controls forever and the user is stuck — a keyboard trap that fails 2.1.2 No Keyboard Trap. Wire Escape first, then Enter.
When a cell holds exactly one interactive control, you can skip the two-mode dance entirely: keep the roving tabindex on that control, and Enter or Space activates it as normal. The two-mode model is the price you pay only for cells that pack in more than one thing.
7. Editable Cells
An editable grid is a special, common case of actionable mode. A read-only grid should say so with aria-readonly="true" on the grid (or on the individual cells that cannot change). Where a cell is editable, the same keys apply: Enter or F2 opens the editor, Escape cancels and restores the original value, and committing the edit (often Enter again) returns to navigation mode and moves focus on.
// An editable cell in navigation mode.
<td tabindex="0" aria-readonly="false">$95,000</td>
// Enter / F2 swaps the text for an input, focus moves into it.
<td tabindex="-1">
<input
type="text"
aria-label="Salary, Alice Johnson"
value="95000"
/>
</td>
// Escape restores "$95,000" and returns focus to the cell (navigation mode).
// Enter commits the new value, returns to navigation mode, moves focus down.Two details matter for a screen reader user. First, the editor needs its own accessible name — the column header alone is not enough once the cell text has been replaced by a bare input, so give it an aria-labelthat identifies both the field and the row (“Salary, Alice Johnson”). Second, if the edit can fail validation, surface the error with aria-invalid and an aria-describedby message, exactly as you would in a form — an editable grid cell is a form control that happens to live in a grid.
8. Selection: Cell, Row, and Range
A grid can let the user select cells, rows, or ranges, and the chosen state belongs on aria-selected — the same property a listbox and a tree use — placed on whatever unit the user selects. Put aria-selected on the gridcell for cell selection, or on the row for whole-row selection.
<!-- Multi-select rows: aria-multiselectable on the grid, an explicit
aria-selected on every row, and a real checkbox as the non-colour cue. -->
<table role="grid" aria-label="Invoices" aria-multiselectable="true">
<tbody>
<tr aria-selected="true">
<td tabindex="0">
<input type="checkbox" checked aria-label="Select invoice 1024" />
</td>
<td tabindex="-1">#1024</td>
<td tabindex="-1">$420.00</td>
</tr>
<tr aria-selected="false">
<td tabindex="-1">
<input type="checkbox" aria-label="Select invoice 1025" />
</td>
<td tabindex="-1">#1025</td>
<td tabindex="-1">$180.00</td>
</tr>
</tbody>
</table>Set aria-multiselectable="true" on the role="grid" when more than one unit can be selected, and give every selectable unit an explicit aria-selected of true or false so a screen reader can announce the state of each. Space toggles the focused unit, Shift+Arrow extends a selection, and Ctrl+A selects all — the conventions a spreadsheet user already expects. If your grid is purely read-only and nothing is selectable, add no aria-selected at all; an attribute that never changes is only noise.
Never show selection by colour alone. A tinted row means nothing to a screen reader without aria-selected, and nothing to a colour-blind user without a second cue. Pair the state with a checkbox, a checkmark, or a bold outline so it survives both 1.4.1 Use of Color and 4.1.2.
9. Telling the User Where They Are: Count and Index
A sighted user reads their position in a big grid from the scrollbar and the row numbers. A screen reader user gets that same orientation from four properties — and they become essential the moment you virtualise or paginate, because then the DOM holds only the visible rows and the browser can no longer count the real data set.
aria-rowcount/aria-colcount— on the grid, the real total number of rows and columns. Set-1when a total is genuinely unknown (an infinite-scroll feed still loading).aria-rowindex— on each row, its 1-based position in the full grid, so the user hears “row 4,500” even though it is the third<tr>in the DOM.aria-colindex— on each cell, its 1-based column position in the full grid. Also useful when a row does not start at column 1 or skips columns.
<!-- A virtualized grid: 10,000 rows total, only a window rendered. -->
<table role="grid" aria-label="Transactions"
aria-rowcount="10000" aria-colcount="4">
<tbody>
<tr aria-rowindex="4500">
<td aria-colindex="1" tabindex="0">2026-07-28</td>
<td aria-colindex="2" tabindex="-1">Acme Corp</td>
<td aria-colindex="3" tabindex="-1">$1,240.00</td>
<td aria-colindex="4" tabindex="-1">Paid</td>
</tr>
<!-- ...only the visible ~30 rows are in the DOM... -->
</tbody>
</table>When the whole grid is in the DOM, the browser computes counts and positions for you and you can skip these. But once you virtualise, only your aria-rowcount and aria-rowindexcan tell the user “row 4,500 of 10,000”. They are the grid's version of the tree's aria-level, aria-setsize and aria-posinset: the programmatic version of the position the layout shows a sighted user. Treat them as part of building a virtualized grid, not as polish.
10. Treegrid: When Rows Expand and Collapse
A role="treegrid" is a grid whose rows form a hierarchy that can expand and collapse — an email list grouped into threads, a file browser with columns, a financial report you drill into. It is the grid keyboard model plus the expand/collapse logic of a tree view: rows carry aria-expanded (on the parent rows only) and aria-level, and when focus is on the first column of a collapsible row, Right Arrow expands it and Left Arrow collapses it — the same context-sensitive arrow behaviour the tree guide covers in depth.
<table role="treegrid" aria-label="Threaded messages">
<tbody>
<tr aria-level="1" aria-expanded="true" aria-posinset="1" aria-setsize="2">
<td tabindex="0">Re: Launch plan</td>
<td tabindex="-1">Priya Shah</td>
<td tabindex="-1">Jul 28</td>
</tr>
<tr aria-level="2" aria-posinset="1" aria-setsize="1">
<td tabindex="-1">Re: Launch plan</td>
<td tabindex="-1">Marco Diaz</td>
<td tabindex="-1">Jul 28</td>
</tr>
</tbody>
</table>Because a treegrid combines two of the hardest patterns on the web, it is the strongest case of all for reaching for a battle-tested implementation rather than hand-rolling one. If you do build it, read the tree view guide for the expand/collapse and focus-restoration details, then layer the grid's two-dimensional navigation on top.
11. Data Grids in React
In React you render the grid from data and keep the active cell, the selection, and any edit state in state, but the accessibility contract does not change: the roles, the roving tabindex, the two-dimensional keyboard handler, the two focus modes, and the count and index properties are all still yours to get right.
function DataGrid({ columns, rows }) {
// Track the active cell as [rowIndex, colIndex] for roving tabindex.
const [active, setActive] = useState([0, 0])
function onKeyDown(e) {
const [r, c] = active
const next = {
ArrowRight: [r, c + 1], ArrowLeft: [r, c - 1],
ArrowDown: [r + 1, c], ArrowUp: [r - 1, c],
}[e.key]
if (!next) return
const [nr, nc] = next
if (nr < 0 || nr >= rows.length || nc < 0 || nc >= columns.length) return
setActive(next) // re-render puts tabIndex=0 on the new cell...
e.preventDefault() // ...and the cell ref's effect calls .focus()
}
return (
<table role="grid" aria-label="Report" onKeyDown={onKeyDown}>
<tbody>
{rows.map((row, r) => (
<tr key={row.id}>
{columns.map((col, c) => (
<td
key={col.key}
tabIndex={active[0] === r && active[1] === c ? 0 : -1}
>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}A data grid is one of the most complex widgets you can build, so the honest advice for production is to lean on a well-tested implementation: React Aria's useTable / Table gives you a fully interactive grid with the roving focus, the two focus modes, selection, and keyboard model already handled; TanStack Table is a headless data layer you pair with the ARIA and keyboard code above; and grid libraries such as AG Grid ship an accessibility mode you should still verify. The same principles carry to other frameworks — see the React, Vue, and Angular guides. Whatever you ship, verify it against the workflow below rather than trusting the README.
How to Test an Accessible Data Grid
Automated tools catch a missing role or a cell with no accessible name, but almost everything that decides whether a grid is usable — the two-dimensional navigation, the two focus modes, the escape from a cell — is a hands-on check that takes a few minutes.
- Tab exactly once. Focus should land on a single cell, and one more Tab should leave the grid entirely. If Tab steps cell by cell, you have the every-cell-
tabindex=0bug. - Navigate in two dimensions. Arrow in all four directions, Home and End along a row, Ctrl+Home and Ctrl+End to the corners. The page must not scroll while you do it, and the arrows must stop at the edges rather than wrap.
- Enter and escape a busy cell. On a cell with controls, press Enter to step in, Tab between the controls, then Escape to step back out to navigation mode. If Escape does nothing, you have a keyboard trap (2.1.2).
- Listen with a screen reader. Each cell should announce its content, its column header, and its position; a virtualized grid should announce the real row and column counts, not the DOM's. The screen reader testing guide has the commands for NVDA and VoiceOver.
- Measure the visuals. The focus indicator and the selection indicator each reach 3:1 (1.4.11); any clickable target inside a cell is at least 24 × 24 px (2.5.8); selection is not colour alone (1.4.1).
Layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor to catch a missing name, role, or state before it ships.
Common Data Grid Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| role="grid" slapped onto a plain data table the user only reads. | It switches the widget from a readable table into an interactive one that demands an arrow-key model — and if that model is not implemented, the cells become far harder to reach than in the native table it replaced (2.1.1, 4.1.2 misuse). | Use a plain semantic <table> with <th scope> headers for tabular data. Reserve role="grid" for when the user actually navigates or edits cells. |
| Every cell (or every control in the grid) has tabindex="0". | The grid stops being a single Tab stop, so a keyboard user must Tab through every cell to get past it and the arrow-key model never engages — a 1,000-cell grid is 1,000 Tab stops (2.1.1, 2.4.3). | Use roving tabindex: one cell at tabindex="0", every other at tabindex="-1", and the arrow keys move the 0. |
| A cell holds several controls, entered with Enter, but there is no Escape back to navigation mode. | Once focus is inside the cell, Tab cycles its controls and the arrows do nothing at grid level, so the user cannot get back out to the rest of the grid — a keyboard trap (2.1.2). | Wire Escape (and Enter/F2 to enter) so actionable mode always has a way back to navigation mode. |
| Arrow keys move between cells but also scroll the page. | The default scroll behaviour fires alongside the cell move, so the viewport jumps every time the user navigates, making the grid unusable by keyboard (2.1.1). | Call e.preventDefault() on every arrow branch you handle so the browser does not also scroll. |
| A virtualized grid shows 30 of 10,000 rows with no aria-rowcount or aria-rowindex. | The screen reader counts only the rows in the DOM and announces "row 4 of 30", so the user has no idea where they are in the real data set (1.3.1, 4.1.2). | Set aria-rowcount / aria-colcount on the grid to the real totals and aria-rowindex / aria-colindex on each rendered row and cell. |
| The selected cell or row is shown only by a background colour, with no aria-selected. | A screen reader user never learns which cells are selected, and colour alone fails colour-blind users (1.4.1, 4.1.2). | Set aria-selected on the cell or row and pair the colour with a non-colour cue such as a checkbox, a checkmark, or a bold outline. |
Accessible Data Grid Checklist
- Right control. The user navigates or edits cells. If they only read the data, use a plain semantic
<table>instead. - Structure on a table.
role="grid"on a real<table>, with<th scope>headers so the header-to-cell relationships come for free (1.3.1). - One Tab stop. Roving tabindex: exactly one cell at
tabindex="0", the rest at-1(2.4.3). - Full 2-D keyboard. All four arrows, Home/End, Ctrl+Home/End, with
preventDefaultso the page does not scroll (2.1.1). - Two focus modes. Enter/F2 enters a busy cell, Escape always returns to navigation mode — no keyboard trap (2.1.2).
- Position when virtualized.
aria-rowcount/aria-colcounton the grid andaria-rowindex/aria-colindexon rows and cells once the DOM no longer holds them all. - Selection exposed.
aria-selectedon the selected cells or rows,aria-multiselectableon the grid, and a non-colour cue (1.4.1). - Visible and reachable. The focus and selection indicators reach 3:1 (1.4.11); any target inside a cell is at least 24 × 24 px (2.5.8).
Work through the full WCAG 2.2 checklist to see the grid in the context of every other requirement.
Check Your Data Grid on a Live Page
Scan any page with our free axe-core-powered auditor to catch a grid with a missing role, cells with no accessible name, or a wrong count — then run the Tab, arrow-key, and Enter/Escape passes above for the failures no scanner can see.
Frequently Asked Questions
What is the difference between a data grid and a table?▾
A table (role="table", or a plain <table> element) presents tabular data the user reads. A grid (role="grid") is an interactive widget the user operates with the arrow keys, like a spreadsheet or an interactive data grid with editable cells, sortable columns, and controls inside the cells. The visible markup can look identical, but the interaction model is completely different: in a table, a screen reader user navigates with their assistive technology's own table-reading commands and every cell is exposed for reading; in a grid, the whole widget is a single Tab stop and the arrow keys move a roving focus between cells. Reach for role="grid" only when the user genuinely navigates or edits cells — never on a table they simply read. Putting role="grid" on a read-only data table switches on an arrow-key interaction model that you then have to implement in full, and if you don't, you have made the table worse, not better.
When should I use role="grid" instead of a plain <table>?▾
Use role="grid" only when the user operates on the cells: navigating cell to cell with the arrow keys as though it were a spreadsheet, editing values in place, or moving through a dense matrix of interactive controls that you want to collapse into a single Tab stop with two-dimensional navigation. For everything else — a pricing table, a comparison table, a report, any tabular data the user reads and does not manipulate — use a plain semantic <table> with <th scope> headers. The native element gives you the header-to-cell relationships, the row and column structure, and the powerful table-reading commands screen reader users already know, all for free and with no JavaScript. A grid is a lot of behaviour to own — the full 2-D keyboard model, roving tabindex, the two focus modes, and the count and index properties when you virtualise — so only take it on when the interaction truly demands it.
What keyboard interactions does an accessible data grid need?▾
The grid is one Tab stop. Once focus is inside, Right and Left Arrow move focus to the next and previous cell in the same row; Up and Down Arrow move to the cell above and below in the same column. Home moves to the first cell in the row and End to the last cell in the row; Ctrl+Home jumps to the first cell of the first row and Ctrl+End to the last cell of the last row. Page Down and Page Up may move by a screenful of rows. When a cell contains an interactive widget, Enter or F2 enters the cell (actionable mode) so Tab can move between the controls inside it, and Escape returns to navigation mode with focus back on the cell. In an editable grid, Enter or F2 opens the editor and Escape cancels it. Every one of these must work with no pointer, or the grid fails 2.1.1 Keyboard — and if there is no Escape out of actionable mode, it also fails 2.1.2 No Keyboard Trap.
What are the two focus modes in a grid — navigation and actionable?▾
A grid manages focus in two modes because the arrow keys can only mean one thing at a time. In navigation mode, the arrow keys move focus from cell to cell across the grid, and each cell is a single stop. That is enough when a cell holds nothing interactive, or holds exactly one widget you can focus directly. The problem comes when a cell holds several focusable controls, or a widget that itself needs the arrow keys — a second grid, a slider, a listbox. You cannot let the same Right Arrow both move to the next cell and operate the widget. The answer is actionable mode: the user presses Enter or F2 on the cell to step inside it, at which point Tab and Shift+Tab move between the controls in that cell and the widget's own keys work normally; Escape steps back out to navigation mode with focus restored to the cell. Providing that Escape route is not optional — without it the user is trapped inside the cell and the grid fails 2.1.2 No Keyboard Trap.
What is roving tabindex and why does a grid need it?▾
Roving tabindex is the focus-management technique that makes a composite widget a single Tab stop while its arrow keys move focus internally. Exactly one focusable element in the grid — one cell, or one widget inside a cell — carries tabindex="0" and is in the Tab sequence; every other carries tabindex="-1", which keeps it focusable by script but out of the Tab order. When the user presses an arrow, you set the old target to tabindex="-1", set the new target to tabindex="0", and call .focus() on it. Without this, a naive grid gives every cell or control tabindex="0", so a keyboard user has to Tab through hundreds of cells to get past the widget and the arrow-key model never engages. A thousand-cell grid becomes a thousand Tab stops. Roving tabindex collapses that to one. The alternative model is aria-activedescendant, where DOM focus stays on the grid container and an attribute points at the active cell; both are valid, but roving tabindex is the more common choice for grids.
Do I need aria-rowcount, aria-colcount, aria-rowindex, and aria-colindex?▾
You need them the moment the DOM does not contain the whole grid — that is, whenever you virtualise or paginate rows. When every row and cell is present, the browser can count them and work out each cell's position, so a screen reader can say "row 4 of 20, column 2 of 6" on its own. But a virtualised grid may have only the thirty visible rows in the DOM out of ten thousand, so the browser's count is wrong. Set aria-rowcount and aria-colcount on the grid to the real totals (use -1 when the total is genuinely unknown), and set aria-rowindex on each row and aria-colindex on each cell to its 1-based position in the full grid, not the DOM. Now the user hears "row 4,500 of 10,000" correctly. They are also useful when a row does not start at column 1 or skips columns. Think of them as the grid's version of the tree's aria-level, aria-setsize, and aria-posinset: the programmatic version of the position a sighted user reads from the scrollbar and the visible layout.
How do I handle selection in a data grid?▾
Use aria-selected, the same property a listbox and a tree use, placed on whatever unit the user selects — the gridcell for cell selection, or the row for whole-row selection. For multiple selection, put aria-multiselectable="true" on the role="grid" container and give every selectable unit an explicit aria-selected of "true" or "false" so a screen reader can announce the state of each. Space typically toggles selection of the focused cell or row, Shift+Arrow extends a selection, and Ctrl+A selects all, mirroring the conventions of a spreadsheet. Whatever the model, never signal selection with a background colour alone: a screen reader user needs aria-selected, and a colour-blind user needs a non-colour cue such as a checkmark, a filled checkbox in a selection column, or a bold outline. If your grid is read-only and nothing is selectable, do not add aria-selected at all — an attribute that never changes only adds noise.
How do I test a data grid for accessibility?▾
Start with the keyboard and no mouse. Tab once — focus should land on a single cell, and one more Tab should leave the grid entirely; if Tab steps cell by cell, you have the every-cell-tabindex-0 bug. Use the arrow keys to move in all four directions, Home and End along a row, and Ctrl+Home and Ctrl+End to the corners, confirming the page never scrolls out from under you. Where a cell has interactive content, press Enter to step in, Tab between the controls, and Escape to step back out — and make sure Escape always works, or you have a keyboard trap. Then listen with a screen reader: each cell should announce its content, its column header, and its position, and the grid should announce the correct row and column counts even when virtualised. Check that selection is exposed through aria-selected and shown by more than colour, that the focus indicator is clearly visible and reaches 3:1 contrast, and that any clickable target inside a cell is at least 24 by 24 CSS pixels. Layer axe-core on top for the mechanical checks, but the keyboard and screen reader passes are what decide whether the grid actually works.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences