Who it affects
A food bank volunteer using a screen reader to check the stock sheet arrows down the Notes column and hears blank, blank, blank, and wonders if the page has failed to load.
Someone with a cognitive disability sees an empty column headed Notes and wonders what they were supposed to write there.
Someone using a screen reader on a table where every cell is a th hears every value announced as a heading, so the numbers and the labels sound the same.
Broken versus fixed
Both examples are live and rendered in a sandboxed frame. The broken one is deliberately broken.
Notes is announced as a column header, but arrowing down from it reads one blank cell after another. Sighted visitors see an empty column and wonder what should be there.
Every heading now has values beneath it. If notes are needed later, add the column back when there is something to say.
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;min-width:60px}th{background:#efe}caption{text-align:left;font-weight:bold;padding-bottom:6px}</style>
<table>
<caption>Stock count, Friday session</caption>
<tr><th scope="col">Item</th><th scope="col">Tins</th><th scope="col">Packets</th><th scope="col">Notes</th></tr>
<tr><th scope="row">Baked beans</th><td>48</td><td>0</td><td></td></tr>
<tr><th scope="row">Pasta</th><td>0</td><td>30</td><td></td></tr>
<tr><th scope="row">Soup</th><td>22</td><td>0</td><td></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;min-width:60px}th{background:#efe}caption{text-align:left;font-weight:bold;padding-bottom:6px}</style>
<table>
<caption>Stock count, Friday session</caption>
<tr><th scope="col">Item</th><th scope="col">Tins</th><th scope="col">Packets</th></tr>
<tr><th scope="row">Baked beans</th><td>48</td><td>0</td></tr>
<tr><th scope="row">Pasta</th><td>0</td><td>30</td></tr>
<tr><th scope="row">Soup</th><td>22</td><td>0</td></tr>
</table>
Test it by hand in two minutes
- Look along each row header and down each column header. Is there at least one cell with real content in the row or column it heads?
- In the Elements panel, search for <th and count the matches. If there are more th cells than td cells, the table is almost certainly marked up wrong.
- With a screen reader, use the table commands to arrow down from each column header. A run of blank announcements means the header has no data.
- Look for a row of th cells used as a divider between groups of rows. Split the table or use a caption instead.
The fix
<tr><th>Session</th><th>Volunteers</th></tr>
<tr><th>Friday</th><th>6</th></tr>
<tr><th scope="col">Session</th><th scope="col">Volunteers</th></tr>
<tr><th scope="row">Friday</th><td>6</td></tr>
The number 6 is data, not a heading. Use td for values and th only for the cell that names the row or column.
Empty placeholder column
<tr><th scope="col">Item</th><th scope="col">Tins</th><th scope="col">Notes</th></tr>
<tr><th scope="row">Soup</th><td>22</td><td></td></tr>
<tr><th scope="col">Item</th><th scope="col">Tins</th></tr>
<tr><th scope="row">Soup</th><td>22</td></tr>
Remove the column until it has content, or write None in the cells so there is something to read.
Totals made bold with th
<tr><th>Total</th><th>70</th></tr>
<tr><th scope="row">Total</th><td class="total">70</td></tr>
Style the total with a class. Total is a row header; 70 is data.
Common mistakes
- Marking every cell as th to make the whole table bold.
- Leaving a column in the template for a value you no longer collect.
- Using a row of th cells as a visual divider between groups of rows.
- Putting totals in th cells because they should stand out; use a td with a class.
WCAG criteria this rule maps to
axe-core tags this rule as WCAG 2.2 level A. It is one of the checks for:
- 1.3.1 Info and Relationships (level A). Official W3C text.
The scanner checks that each th, and each cell with role columnheader or rowheader, has at least one filled-in data cell in the row or column it heads. It cannot tell you which fix is right: adding the missing data, or removing the header. 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
td-has-header
In a data table, every cell that holds a value needs a header cell that says what the value is.
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.