Tables Rule

Every table header must describe some data th-has-data-cells

A th tells screen readers that a cell is the heading for the row or column it sits in. If that row or column has no data cells, or every cell in the table is a th, the heading describes nothing. Screen readers announce headers that lead nowhere, and sighted visitors see a column that is always blank. Either put data under the header or take the header out.

Websight reports this as Table headers in a data table must refer to data cells

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.

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.

Fixed

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
Broken
<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>
Fixed
<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

  1. 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?
  2. 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.
  3. 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.
  4. 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

Before
<tr><th>Session</th><th>Volunteers</th></tr>
<tr><th>Friday</th><th>6</th></tr>
After
<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

Before
<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>
After
<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

Before
<tr><th>Total</th><th>70</th></tr>
After
<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:

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.

Scan your own page for this and 63 other checks

Open any page in Chrome, click Websight, and get a score out of 100 with every issue explained and a draft fix snippet per element.

Add Websight to Chrome

Free scans, no account, nothing leaves your computer. Automated tools find only part of the problems; the extension says so on every report.