Accessible Data Tables: WCAG Markup, Headers, and Screen Reader Patterns
Data tables break for screen reader users when headers aren't associated correctly. Here's the markup that actually works.
Data tables are one of the most common components on the web, and one of the most consistently broken for screen reader users. The problem is rarely visual. A table can look perfectly organized on screen while being almost impossible to understand with a screen reader, because the markup never tells assistive technology which header belongs to which cell.
This guide covers the markup that actually associates headers with data, the patterns for complex tables, and why most automated scanners give tables a passing grade they don't deserve.
Why tables break for screen reader users
A sighted user reads a table two-dimensionally. They glance at the column header above a number and the row label to its left, and instantly know that the cell means "Q3 revenue for the EMEA region." Screen reader users don't get that spatial context for free. The screen reader reads cells in source order, one at a time. Without explicit header associations in the markup, a user navigating to a single cell hears "1,240" with no idea what it represents.
The fix is to give the table real semantic structure so the screen reader can announce the relevant headers as the user moves between cells.
Start with real table markup
The single most damaging mistake is building a table out of <div> elements styled with CSS grid or flexbox. It may look identical, but a screen reader sees a pile of generic containers with no rows, no columns, and no header relationships. If your data is tabular, use a real <table>.
<table>
<caption>Quarterly revenue by region (USD, thousands)</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">EMEA</th>
<td>980</td>
<td>1,110</td>
<td>1,240</td>
</tr>
</tbody>
</table>
Use scope to associate headers
The scope attribute is what turns a styled grid into a navigable table. Use scope="col" on column headers and scope="row" on row headers. With both in place, a screen reader navigating to the "1,240" cell announces "EMEA, Q3, 1,240" instead of a bare number. Row headers belong in a <th>, not a <td>, even when they sit in the first column of the body.
Add a caption
The <caption> element gives the table an accessible name and is announced when a screen reader user enters the table. It tells them what the table contains before they start exploring cells. A caption is far more reliable than a nearby heading or paragraph, because the relationship is built into the table itself rather than inferred from layout.
Complex tables: headers and id
Some tables can't be described by a simple row/column grid, for example tables with multiple levels of headers or merged cells spanning groups. For these, scope alone isn't enough. Associate each data cell with its headers explicitly using id on the header cells and headers on the data cells.
<th id="emea">EMEA</th>
<th id="q3">Q3</th>
<td headers="emea q3">1,240</td>
This is more verbose, so reserve it for genuinely complex tables. For the vast majority of tables, scope is simpler and less error-prone. The best move is usually to split a complex table into several simple ones if the data allows it.
Responsive tables without breaking semantics
Tables are hard to fit on small screens, and a common shortcut is to switch the table to display: block with CSS at narrow widths. The catch: changing the display property can strip a table's semantics in some browser and screen reader combinations, so the header associations you carefully built quietly stop working. Prefer letting the table scroll horizontally inside a container, and if the container scrolls, make it keyboard focusable and give it an accessible label so users know it's scrollable.
Why scanners miss table problems
Automated tools are good at catching a table with zero header cells, but they can't judge whether the headers you used are the correct ones. A scanner has no way to know that a cell's true row header is in the wrong column, or that a layout table should never have had headers at all, or that a caption describes the table inaccurately. These are semantic judgments that require understanding the data. That gap is exactly why table accessibility still needs a human in the loop and a tool that goes beyond the basic rules engine.
A quick checklist
- Use a real
<table>, never<div>elements, for tabular data. - Mark column headers with
<th scope="col">and row headers with<th scope="row">. - Add a
<caption>so the table has an accessible name. - For complex tables, use
idandheaders, or split into simpler tables. - Avoid layout tables; use CSS for layout instead.
- Keep table semantics intact when making tables responsive.
- Test with a real screen reader by navigating cell to cell.
Get the structure right and a data table becomes one of the most accessible components you can ship: predictable, navigable, and understandable no matter how a person reads it.