Accessible Data Tables
A data table’s accessibility lives almost entirely in one thing: whether the markup says which cell is a header for which data. Sighted users read that from position and weight; a screen reader user gets it only from the code. This guide covers the whole foundation, from the semantic table, caption, and th scope structure to complex headers, responsive tables that survive 320 pixels, sortable and interactive tables, and when a plain table beats an ARIA grid. Mapped to WCAG 2.2, with copy-ready HTML.
What Actually Makes a Table Accessible
A data table exists to communicate relationships: this value belongs to this row and this column. A sighted reader reconstructs those relationships instantly from the grid layout, from the bold header row, from the fact that the leftmost column reads like labels. None of that is available to a screen reader unless the markup encodes it. The accessible version of a table is not a caption bolted on afterwards or an ARIA attribute sprinkled on top; it is a table whose header cells are marked as header cells and tied to the data they describe.
This matters because screen readers do not read a table straight through. They offer a dedicated table navigation mode: the user moves cell by cell with a modifier and the arrow keys, and as they move, the software announces each value along with its column and row headers. Moving across a row might read “Price, 19 dollars” then “Stock, 42”; moving down a column re-announces the column header with each cell. That is only possible when the headers are real <th> cells with a scope. Mark the headers as plain <td>, and the user hears a stream of bare numbers with no idea what any of them mean.
The one test that matters
Turn on a screen reader, move into the middle of your table, and land on a single data cell. If the software tells you both what the value is and which column and row it belongs to, your header associations are correct. If it reads only the raw value, the relationships are missing, and no amount of styling will supply them.
One distinction to settle first: data tables versus layout tables. A data table presents rows and columns of related information. A layout table abuses the <table> element to position unrelated page content, a practice left over from the pre-CSS web. This guide is entirely about data tables. Do not lay out pages with tables: modern CSS grid and flexbox do it better and keep the reading order honest. If you are stuck with a legacy layout table, role="presentation" strips its table semantics so assistive technology ignores the structure, but rebuilding it in CSS is the real fix.
How Tables Map to WCAG 2.2
The highlighted row, 1.3.1 Info and Relationships, is the criterion this whole guide serves: the header-to-cell relationships shown visually must be present in the markup. The rest of the table covers the criteria a data table brushes up against, from naming the table to keeping it usable at 320 pixels.
| Criterion | Level | How it applies to tables |
|---|---|---|
| 1.3.1 Info and Relationships | A | The header-to-cell relationships conveyed by layout must exist in code. This is what <th>, scope, and the headers and id method are for. |
| 1.3.2 Meaningful Sequence | A | The reading order of the table in the DOM must make sense. Do not reorder rows or cells with CSS in a way the source order contradicts. |
| 2.4.6 Headings and Labels | AA | The <caption> names the table and header cells describe their columns and rows. Names must be meaningful, not “Column 1”. |
| 1.4.10 Reflow | AA | At 320 CSS pixels the table must not force two-dimensional scrolling of the whole page. A labelled horizontal scroll region for the table itself is the standard answer. |
| 1.1.1 Non-text Content | A | Icons, status dots, and sparklines inside cells need a text alternative, or the value they encode is invisible to a screen reader. |
| 1.4.1 Use of Color | A | A cell whose meaning is carried by colour alone, a red figure for a loss, needs a second cue such as a sign, label, or icon with a name. |
| 1.4.11 Non-text Contrast | AA | If borders or zebra striping are the only thing separating rows and columns, those visual boundaries must meet 3 to 1 against their background. |
| 2.1.1 Keyboard | A | Sort buttons, row checkboxes, and per-row actions inside cells must be fully keyboard operable, and a scrollable table region must be reachable by keyboard. |
For the full wording of each criterion, browse the WCAG 2.2 reference. 1.3.1 is the one that fails most often on tables, almost always because header cells are marked as <td> or carry no scope.
1. The Minimum Viable Accessible Table
Most of a table’s accessibility comes from using the semantic elements HTML already gives you, in the right places. The full set is small: <table> wraps everything, <caption> names it, <thead>, <tbody>, and <tfoot> group the rows, <tr> is a row, <th> is a header cell, and <td> is a data cell. Here is a complete, correct table with a header row:
<table>
<caption>Q2 2026 sales by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Units sold</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>$48,160</td>
</tr>
<tr>
<th scope="row">South</th>
<td>987</td>
<td>$39,480</td>
</tr>
<tr>
<th scope="row">West</th>
<td>1,530</td>
<td>$61,200</td>
</tr>
</tbody>
</table>Three decisions in that markup carry the accessibility, and each one is worth naming:
- The
<caption>is the table’s name. It must be the first child of<table>. A screen reader announces it on entry and lists it in the tables menu, so the user knows what they are looking at before they explore. It renders as visible text above the table, which helps everyone. - The first row uses
<th scope="col">. These are the column headers.scope="col"tells assistive technology that each one labels the whole column beneath it. - Each row’s first cell is a
<th scope="row">. “North”, “South”, and “West” are not data, they are the labels for their rows, so they are header cells too. This is the step teams forget most: a table with a bold top row but plain<td>down the left reads its numbers with a column name but no row name.
A quick note on <thead> and <tbody>: they group rows into structural sections and are good practice, and <thead> keeps header rows repeating when a long table prints. They are not what associates headers with cells, though, that is the job of <th> and scope. A table with correct header cells but no <thead> is still accessible; a table with a <thead> full of <td> is not.
2. scope: The Load-Bearing Attribute
scope tells assistive technology the direction a header cell applies. It takes four values, and two of them cover almost every table:
| Value | The header applies to | Use it for |
|---|---|---|
| scope="col" | Every data cell in its column | The header row across the top of the table. |
| scope="row" | Every data cell in its row | The label cell at the start of each row. |
| scope="colgroup" | A group of columns it spans | A header that spans several columns via colspan. |
| scope="rowgroup" | A group of rows it spans | A header that labels a block of rows via rowspan. |
You might have heard that browsers can infer the scope from position, so you can leave it off. Do not rely on that. The inference is unreliable across screen readers, and it breaks down precisely when the table gets interesting, once there are both column headers and row headers, or a spanning header. The rule is simple and worth making absolute: every <th> gets a scope. It is a few characters that removes all ambiguity.
When a header spans multiple columns, colspan paired with scope="colgroup"handles the common case. Here a “Contact” header sits above two columns:
<table>
<caption>Team directory</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="colgroup" colspan="2">Contact</th>
</tr>
<tr>
<td></td>
<th scope="col">Email</th>
<th scope="col">Phone</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Alice Nguyen</th>
<td>alice@example.com</td>
<td>555-0142</td>
</tr>
</tbody>
</table>Once a table has two header rows like this, though, you are at the edge of what scope expresses cleanly. That is the signal to look at the next section.
3. Complex Tables: the headers and id Method
When a table has multiple levels of headers, or headers that apply to cells in a way the grid position cannot express, scope runs out. The headers and id method handles any structure. Give every header cell a unique id, then on each data cell, list the ids of every header that applies in a space-separated headers attribute:
<table>
<caption>Shipping cost by weight and zone</caption>
<thead>
<tr>
<th id="weight" scope="col">Weight</th>
<th id="zone-a" scope="col">Zone A</th>
<th id="zone-b" scope="col">Zone B</th>
</tr>
</thead>
<tbody>
<tr>
<th id="w-light" scope="row" headers="weight">Under 1 kg</th>
<td headers="w-light zone-a">$5</td>
<td headers="w-light zone-b">$8</td>
</tr>
<tr>
<th id="w-heavy" scope="row" headers="weight">1 to 5 kg</th>
<td headers="w-heavy zone-a">$9</td>
<td headers="w-heavy zone-b">$14</td>
</tr>
</tbody>
</table>Now the $14cell announces “1 to 5 kg, Zone B, 14 dollars” because its headers attribute names both the row header and the column header explicitly. The method is exhaustive and unambiguous. It is also verbose and fragile: every id must be unique, every reference must resolve, and it is easy to let them drift out of sync when the table changes.
Before you reach for headers and id, try to simplify
A table complex enough to need the headers and id method is usually hard for everyone, not just screen reader users. More often than not, a table with several header levels can be split into two or three simpler tables, each with its own caption and a straightforward scope structure. That is easier to build, easier to maintain, and easier to read for all users. Treat headers and id as the tool for the genuinely irreducible case, not the default for anything with more than one header row.
Between the two extremes, scope="colgroup" and scope="rowgroup"cover many “grouped header” tables without the id bookkeeping, so try those before the full headers and id approach.
4. Naming, Describing, and Empty Cells
Caption is the name; add a description only if needed
The <caption> gives the table its short name. When a table also needs a longer explanation, how to read it, a data source, a note about units, do not resurrect the obsolete summary attribute, which HTML5 removed and modern assistive technology ignores. Put the explanation in visible prose near the table, or associate a paragraph with aria-describedby:
<p id="table-note">
Figures are in thousands and exclude refunds.
</p>
<table aria-describedby="table-note">
<caption>Monthly active users, 2026</caption>
...
</table>A <figure> with a <figcaption> wrapping the table is another valid pattern when the table is a self-contained figure you reference from the text. If the surrounding heading already names the table, you can visually hide the caption with an sr-only utility class rather than dropping it, keeping the name for screen readers without visual duplication, though a visible caption is usually the friendlier choice.
Empty cells
An empty <td>is announced as an empty cell, which is fine when the value is genuinely absent, but ambiguous when it means something specific like “none” or “not applicable”. If the blank carries meaning, put the meaning in the cell rather than leaving it empty, so a screen reader user is not left guessing whether the data is zero, unknown, or missing. The one empty cell that is expected and correct is the top-left corner of a table with both column and row headers, the intersection that labels nothing; leaving that <td> or <th> empty is the conventional, well-understood choice.
5. Responsive Tables Without Breaking the Semantics
This is where table accessibility most often goes wrong. A wide table cannot simply shrink to fit a phone, and 1.4.10 Reflow says the page must not require two-dimensional scrolling at 320 CSS pixels. There are two mainstream answers, and one of them has a hidden trap.
The safe default: a labelled scroll region
Keep the table exactly as it is and let it scroll horizontally inside a container. The important detail is making that container a focusable, named region so keyboard users can scroll it and screen reader users know what it is. A bare overflow-x: auto div cannot be scrolled by keyboard; adding tabindex="0" and a label fixes that:
<div
role="region"
aria-label="Quarterly revenue, scrollable"
tabindex="0"
style="overflow-x: auto;"
>
<table>
<caption>Quarterly revenue by product line</caption>
...
</table>
</div>This keeps the full table semantics intact, satisfies reflow by scoping the scrolling to the table rather than the page, and is reachable by keyboard. It is the pattern that cannot silently break, and it should be your first choice.
The card pattern and its trap
The other popular approach collapses each row into a stacked “card” on narrow screens, with each value prefixed by its column label via a CSS ::before pseudo-element fed from a data-label attribute. It can look great. The trap is that it is usually built by setting display: block (or grid) on the table, tr, th, and td elements, and changing the display of a table element removes its table role from the accessibility tree. The table still looks like a table, but a screen reader no longer sees rows, columns, or header associations. You have traded a scroll for a broken table.
If you use the card pattern, do one of these
Either re-declare the roles explicitly, adding role="table" to the table, role="row" to each row, role="cell" to data cells, and role="columnheader" or role="rowheader" to headers, so the display change does not strip them; or make sure the visible data-labeltext carries each value’s meaning so the card reads correctly even without table semantics. Test the result with a screen reader at mobile width, because the breakage is completely invisible on screen.
A related reflow tie-in: 1.4.5 Images of Text and 1.4.4 Resize Text both fail if you render the table as a fixed-width screenshot to avoid the layout problem. Keep the table as real text and solve the width with layout, never with an image.
6. Sortable and Interactive Tables
Reading a table is one thing; sorting it, selecting rows, and acting on them adds controls that each need their own accessibility. The key point: a sortable or selectable table is still a table, not a grid. You add controls inside a normal semantic table; you do not switch on the role="grid" interaction model.
Sortable columns with aria-sort
Put a real <button> inside the <th> so the sort control is keyboard operable (2.1.1), and set aria-sort on the <th> to announce the current sort. Only one column carries an active aria-sort value at a time; the values are ascending, descending, none, and other:
<th scope="col" aria-sort="ascending">
<button type="button">
Revenue
<span aria-hidden="true">▲</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button">Region</button>
</th>When the user activates the button, re-sort the rows, move aria-sort to the newly active column (and set the others back to none), and update the visible arrow. The arrow glyph is decorative, so hide it from assistive technology with aria-hidden: the sort state is already conveyed by aria-sort, and doubling it up in the button text would be noise. Some screen readers announce thearia-sort change on their own; if you want to guarantee feedback, mirror it in a polite live region such as “Sorted by revenue, ascending”.
Row selection and per-row actions
A checkbox or action button inside a cell must have an accessible name that includes the row context, because a screen reader user arriving on it out of order will not have read the row. “Select” repeated down a column is useless; “Select row for Alice Nguyen” is not. Build the name from the row’s header text with aria-label or a visually hidden span:
<td>
<button type="button" aria-label="Delete invoice INV-2041">
<TrashIcon aria-hidden="true" />
</button>
</td>The line between a table and a grid
If the user reads, sorts, and clicks the occasional control, keep a plain <table>: the screen reader handles cell navigation for free, and each control is just a normal focusable element in the Tab order. The moment the user needs to navigate cell to cell with the arrow keys or edit values in place, as in a spreadsheet, you have crossed into grid territory and owe the full role="grid" keyboard model. That is a much larger build, covered end to end in the accessible data grid guide. Most tables never need it.
7. ARIA Table Roles: the Fallback, Not the Upgrade
ARIA provides a full set of table roles, role="table", role="rowgroup", role="row", role="columnheader", role="rowheader", and role="cell", that let you build a table out of non-table elements such as divs. They exist for one situation: when you genuinely cannot use a real <table>, most often a virtualised table that renders only the visible rows for performance and needs full control over the DOM.
This is the first rule of ARIA in action: if a native element already does the job, use it instead of rebuilding it. A div-based ARIA table makes you reconstruct by hand everything <table> gives you for free, and any gap breaks the reading:
- Every element must mirror the native structure exactly:
role="row"only ever contains cells, and every cell sits inside a row inside a rowgroup inside the table. - Header cells need
role="columnheader"orrole="rowheader"; the header-to-cell association thatscopegave you for free now has to come from that structure. - Because a virtualised table has only some rows in the DOM, you must add
aria-rowcountandaria-colcounton the table andaria-rowindexandaria-colindexon the rows and cells, so the user hears “row 40 of 10,000” instead of “row 40 of 20”.
The verdict is the same one that runs through every part of this guide: a real <table> with <th scope> is more robust, better supported, and less code than any div-and-ARIA reconstruction. Reach for the ARIA table roles only when there is truly no alternative, and when you do, mirror the native element precisely.
8. Testing a Table
Automated checkers and manual testing catch different problems, and a table needs both.
Automated: the first pass
Scanners such as axe and WAVE reliably flag the structural mistakes: a <th> with an empty text, a table with no header cells at all, a headers attribute pointing at an id that does not exist, and a layout table used for data. They cannot judge whether your header labels are meaningful or whether scope points the right way, so treat a clean automated report as necessary, not sufficient. See the automated versus manual testing guide for where the line falls.
Manual: read it with a screen reader
The definitive test is table navigation mode. Move cell by cell and listen for the header being announced with each value:
- In NVDA and JAWS, move with Ctrl plus Alt plus the arrow keys. Moving right should read the column header with the value; moving down should re-announce it.
- In VoiceOver, interact with the table and move with Control plus Option plus the arrow keys.
- Open the screen reader’s list of tables (for example NVDA’s elements list) and confirm your table appears there, named by its caption.
Then check the non-screen-reader layers: zoom the browser to 400 percent and narrow the viewport to 320 pixels to confirm the table reflows without trapping content, and Tab through the page to confirm any scroll region, sort buttons, and row controls are reachable and operable by keyboard. For the full end-to-end routine, the website accessibility audit guide puts these steps in order.
Common Table Mistakes & How to Fix Them
These are the table errors that turn up most in real-world audits. Each one is a small markup decision with an outsized effect on whether the table reads at all.
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| Using a <table> to lay out a page instead of to present data. | A screen reader announces layout content as a data table with a row and column count and offers table navigation for it, which is confusing and often fails reflow (1.4.10) and resize text (1.4.4). | Lay out pages with CSS grid or flexbox. If a layout table is unavoidable, add role="presentation" so assistive technology ignores its structure. |
| Header cells marked up as bold <td> instead of <th>. | A styled <td> looks like a header but carries no header semantics, so a screen reader never associates it with the data cells and reads the values with no context (fails 1.3.1). | Use <th> for every header cell, and give each one an explicit scope of col or row. |
| A <th> with no scope attribute. | Without scope, assistive technology has to guess which cells the header applies to, and the guess is unreliable across screen readers, especially once the table has both column and row headers. | Add scope="col" to column headers and scope="row" to row headers so every association is explicit. |
| No <caption>, so the table has no name. | The table is announced only as "table" and shows up unlabelled in the screen reader's list of tables, so the user cannot tell what it holds or which table to jump to (weakens 2.4.6). | Add a <caption> as the first child of the <table> describing what the data is. Style or visually hide it if a nearby heading already names it. |
| Setting display:block or display:grid on table, tr, and td for a responsive layout. | Changing the display of table elements removes their table roles from the accessibility tree, so the header-to-cell relationships silently disappear even though the table still looks right. | Prefer wrapping the table in a labelled, focusable overflow-x scroll region. If you do reflow to cards, re-add role="table", role="row", and role="cell", or ensure each value reads with its label. |
| role="grid" applied to a table the user only reads. | role="grid" switches on an arrow-key interaction model you then have to implement in full; leave it unimplemented and you have made the table harder to use, not easier. | Use a plain <table> with <th scope> for data the user reads. Reserve role="grid" for tables the user navigates or edits cell by cell. |
| Header information locked inside an image or a merged visual header with no markup. | A column labelled only by a graphic, or a spanning header shown by visual grouping alone, gives a screen reader nothing to announce, so the relationship is lost (fails 1.1.1 and 1.3.1). | Put header text in real <th> cells. Use scope="colgroup" or the headers and id method to express spanning headers, not visual grouping alone. |
The Accessible Table Checklist
- Real table, real data. The content is genuine tabular data in a semantic
<table>, not a layout table and not a grid of divs. - It has a caption. A
<caption>is the first child of the table and names what the data is. - Headers are
<th>. Every column header and every row label is a<th>, never a styled<td>. - Every
<th>has a scope.scope="col"on column headers,scope="row"on row headers, colgroup or rowgroup on spanning headers. - Complex tables are justified. You used headers and id only where
scopecould not express the structure, and considered splitting the table first. - Meaningful cell content. Icons and colour-coded cells have text alternatives; blanks that mean something say so; colour is never the only cue.
- It reflows. At 320 pixels the table scrolls inside a labelled, focusable region, or reflows to cards without losing its semantics.
- Controls are named and keyboard operable. Sort buttons, row checkboxes, and actions work by keyboard and carry the row context in their accessible name.
- ARIA roles only if forced. You reached for
role="table"only when a native table was impossible, and mirrored its structure exactly. - Verified with a screen reader. In table navigation mode, a single cell announces its value with its column and row headers.
Get the Relationships Right
Start from the criterion tables exist to serve, then decide whether you need a table the user reads or a grid the user operates.
Frequently Asked Questions
What makes a data table accessible?▾
A data table is accessible when the markup encodes which cell is a header for which data, so a screen reader can tell the user what a value means as they move through the table. Three things do most of the work: use a real <table> element rather than a grid of divs, give it a <caption> so it has a name, and mark every header cell as a <th> with an explicit scope of col or row so each data cell is tied to its column and row headers. Sighted users infer these relationships from position and visual weight; a screen reader user gets them only from the code. Get the header associations right and the table reads correctly cell by cell. Everything else, from contrast to responsive behaviour, builds on that foundation.
What is the difference between the scope attribute and the headers and id attributes?▾
Both associate data cells with their header cells, but they suit different tables. scope is the simple, preferred method for regular tables: put scope="col" on each column header and scope="row" on each row header, and the browser works out which headers apply to each cell from the grid position. It is concise and covers the vast majority of tables. The headers and id method is for genuinely complex tables where scope cannot express the relationships: give every header cell a unique id, then on each data cell list the ids of every header that applies in a space-separated headers attribute. It is explicit and handles any structure, including multi-level and irregular headers, but it is verbose and easy to get out of sync. The rule of thumb: reach for scope first, and only move to headers and id when a table has multiple header levels or spanning headers that scope cannot describe. If you find yourself needing headers and id, first ask whether the table could be split into simpler tables instead.
Is a caption required on every table?▾
It is not strictly required by WCAG, but a <caption> is the single easiest accessibility win for a table and you should add one to every data table. The caption is the table's accessible name: it is announced when a screen reader user lands on the table, and it is what appears in the list of tables the user can jump between, so it is how they decide whether this table is the one they want. Without it, a table is announced only as "table" with no indication of what it holds. Put the <caption> as the very first child of the <table>; it renders as visible text above the table by default, which helps everyone, and you can style or visually hide it if the surrounding heading already names it, though a visible caption is usually better. A caption is far more reliable than the obsolete summary attribute, which you should not use.
How do I make a wide table responsive without breaking its accessibility?▾
The safest approach is to keep the table intact and let it scroll horizontally inside a labelled container: wrap the <table> in a <div> with overflow-x set to auto, and make that wrapper focusable and named by giving it tabindex="0", role="region", and an aria-label such as "Quarterly revenue, scrollable". That keeps the full table semantics and lets keyboard users scroll it, which a plain overflow container does not allow. The other common approach, collapsing the table into stacked cards on small screens with CSS, is riskier: setting display:block or display:grid on the table, tr, th, and td elements removes their table roles from the accessibility tree, so the header associations vanish. If you use the card pattern, either re-add the roles explicitly with role="table", role="row", role="cell", and so on, or make sure each value still reads with its label in the visible text. When in doubt, the scrollable region is the pattern that cannot silently break.
Can I use divs with role=table instead of a real HTML table?▾
You can, but you almost never should. The ARIA table roles, role="table", role="row", role="cell", role="columnheader", role="rowheader", and role="rowgroup", exist for the rare case where you cannot use a real <table>, such as a virtualised table built from divs for performance. They make you rebuild by hand everything the native element gives you for free: the row and column structure, the header-to-cell relationships, and, when you virtualise, the aria-rowcount, aria-colcount, aria-rowindex, and aria-colindex properties so the user hears "row 40 of 10,000". Miss one and the table reads incorrectly. This is the first rule of ARIA in practice: if a native element does the job, use it. A real <table> with <th scope> is more robust, better supported, and less code than any div-based reconstruction, so reserve the ARIA roles for when there is truly no alternative.
How do screen reader users actually read a table?▾
Screen readers have a dedicated table navigation mode. Instead of reading straight through in source order, the user moves cell by cell with a modifier plus the arrow keys: in NVDA and JAWS that is Ctrl plus Alt plus the arrow keys, and in VoiceOver it is Control plus Option plus the arrow keys while interacting with the table. As they move, the screen reader announces the contents of the cell along with its column and row headers, so moving right along a row reads "Price, 19 dollars" then "Stock, 42", and moving down a column re-announces the column header with each value. This only works when the headers are marked up as <th> with scope; if the header cells are plain <td>, the screen reader has nothing to announce and the user hears bare numbers with no idea what they mean. The user can also pull up a list of every table on the page, where each table is identified by its caption, and jump straight to the one they want.
What is wrong with using a table for page layout?▾
Using a <table> to position page content, rather than to present tabular data, forces relationships onto content that has none. A screen reader announces it as a data table, tells the user how many rows and columns it has, and offers table navigation for what is really just a layout, which is confusing and slows them down. It also tends to fix the layout in a way that fails reflow at 1.4.10 and resize text at 1.4.4. Modern CSS with flexbox or grid does layout far better and keeps the reading order in the markup, so there is no reason to lay out a page with a table. If you have inherited a layout table you cannot remove, add role="presentation" to strip its table semantics so assistive technology ignores the structure, but the real fix is to rebuild the layout in CSS. Reserve <table> for actual rows and columns of related data.
When should I use a data grid instead of a plain table?▾
Use a plain semantic <table> whenever the user reads the data and, at most, sorts or selects rows: pricing tables, comparison tables, reports, dashboards, any tabular content. The native table gives a screen reader the header relationships and the powerful table-reading commands for free, with no JavaScript. Reach for role="grid" only when the user operates on the cells as though the table were a spreadsheet: navigating cell to cell with the arrow keys, editing values in place, or working through a dense matrix of controls that you want to collapse into a single Tab stop with two-dimensional keyboard navigation. A grid is a large amount of behaviour to build and own, including the full arrow-key model, roving tabindex, and the navigation-versus-actionable focus modes, so only take it on when the interaction genuinely demands it. Adding sortable headers or row checkboxes to a table does not make it a grid; it is still a table the user reads.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences