Who it affects
Someone using a screen reader to find their bin day moves to a cell and hears only: Tuesday. With no header they cannot tell whether that is general waste, recycling or garden waste, or which street the row is about.
Someone with low vision who zooms the page to 400% sees one or two cells at a time. Marked-up headers let their screen reader say what the column off screen was called; plain cells cannot.
Someone reading on a Braille display gets the cell text with nothing to say which column it came from.
Broken versus fixed
Both examples are live and rendered in a sandboxed frame. The broken one is deliberately broken.
Moving to the third cell of the Mill Lane row, a screen reader says only: Tuesday. Nothing tells the listener it is the recycling day. The bold top row is just styling.
The same cell is announced as: Recycling, Mill Lane, Tuesday. Column, row, then value.
Show the markup behind both examples
<style>body{font-family:system-ui;margin:0;padding:12px}table{border-collapse:collapse;font-size:14px}td,th{border:1px solid #bbb;padding:6px 10px;text-align:left}tr:first-child td{font-weight:bold;background:#eee}</style>
<table>
<tr><td>Street</td><td>General waste</td><td>Recycling</td><td>Garden waste</td></tr>
<tr><td>Mill Lane</td><td>Monday</td><td>Tuesday</td><td>Friday</td></tr>
<tr><td>Church Row</td><td>Monday</td><td>Wednesday</td><td>Friday</td></tr>
<tr><td>Station Road</td><td>Tuesday</td><td>Thursday</td><td>Saturday</td></tr>
<tr><td>The Green</td><td>Tuesday</td><td>Thursday</td><td>Saturday</td></tr>
</table>
<style>body{font-family:system-ui;margin:0;padding:12px}table{border-collapse:collapse;font-size:14px}td,th{border:1px solid #bbb;padding:6px 10px;text-align:left}thead th{background:#eee}</style>
<table>
<thead>
<tr><th scope="col">Street</th><th scope="col">General waste</th><th scope="col">Recycling</th><th scope="col">Garden waste</th></tr>
</thead>
<tbody>
<tr><th scope="row">Mill Lane</th><td>Monday</td><td>Tuesday</td><td>Friday</td></tr>
<tr><th scope="row">Church Row</th><td>Monday</td><td>Wednesday</td><td>Friday</td></tr>
<tr><th scope="row">Station Road</th><td>Tuesday</td><td>Thursday</td><td>Saturday</td></tr>
<tr><th scope="row">The Green</th><td>Tuesday</td><td>Thursday</td><td>Saturday</td></tr>
</tbody>
</table>
Test it by hand in two minutes
- Right click a cell with a value in it and choose Inspect. Check whether the top row and the first column use th rather than td.
- Turn on NVDA or VoiceOver, move into the table and use the table commands (Ctrl+Alt+arrow keys in NVDA, Ctrl+Option+arrow keys in VoiceOver). Each move should announce the new column or row heading before the value.
- Cover the top row and left column with your hand and look at one cell. If you cannot say what it means, neither can a screen reader.
- Count the rows and columns. Tables of 3 by 3 or smaller are not flagged by the scanner but still read better with headers.
The fix
<tr><td>Street</td><td>Recycling</td></tr>
<tr><td>Mill Lane</td><td>Tuesday</td></tr>
<tr><th scope="col">Street</th><th scope="col">Recycling</th></tr>
<tr><th scope="row">Mill Lane</th><td>Tuesday</td></tr>
Use th with scope="col" for the top row, and th with scope="row" for the first cell of each row when that cell names the row. Keep td for the values.
Header row inside thead
<table>
<tr><td>Street</td><td>Recycling</td></tr>
<tr><td>Mill Lane</td><td>Tuesday</td></tr>
</table>
<table>
<thead><tr><th scope="col">Street</th><th scope="col">Recycling</th></tr></thead>
<tbody><tr><th scope="row">Mill Lane</th><td>Tuesday</td></tr></tbody>
</table>
thead and tbody are optional but they make the header row obvious to the next person editing the table, and they let the header repeat when printing.
Table used only for layout
<table>
<tr><td><img src="logo.svg" alt="Logo"></td><td>Opening hours</td></tr>
</table>
<div class="row">
<img src="logo.svg" alt="Logo">
<p>Opening hours</p>
</div>
If the table only positions things on the page it has no headers to add. Lay it out with CSS, or as a last resort add role="presentation" to the table.
Common mistakes
- Making the first row bold with CSS instead of using th, which looks like a header and sounds like nothing.
- Using th for every cell, so the table has no data at all (see th-has-data-cells).
- Putting the headers in a separate table above the data so they line up on screen but are not connected.
- Adding scope to td cells; scope only works on th.
- Using icons for the header row with no alt text, so the header is a th with nothing in it.
WCAG criteria this rule maps to
axe-core tags this rule as WCAG 2.2 level A. It is a check (not run by Websight) for:
- 1.3.1 Info and Relationships (level A). Official W3C text.
The scanner checks tables larger than 3 by 3 and flags filled-in td cells with no th in their row or column and no headers attribute. It cannot tell whether the headers it finds are the right ones for that cell, or whether a table is really a layout grid. This check is tagged experimental in axe-core, so some scanners leave it off. Automated tools find only part of the accessibility problems on a page; a pass on this rule is not a pass on the criterion.
Related rules
th-has-data-cells
A th tells screen readers that a cell is the heading for the row or column it sits in.
A
The headers attribute must point at header cells in the same table
td-headers-attr
The headers attribute on a cell lists the ids of the header cells that describe it.
not run A
Table captions must use the caption element
table-fake-caption
A table's title belongs in a caption element, directly after the opening table tag.