Tables Rule

Every data cell in a table needs a header td-has-header

In a data table, every cell that holds a value needs a header cell that says what the value is. Screen readers read the header out alongside the cell, so a listener can tell that Tuesday is the recycling day for Mill Lane and not something else. The check runs on tables bigger than 3 by 3 and flags any filled-in td with no th in its row or column and no headers attribute.

Websight does not run this check. axe-core marks the td-has-header rule as experimental, and rules with that tag are skipped when scanning by WCAG level. Other tools may still report it as Non-empty <td> elements in larger <table> must have an associated table header. Test it by hand using the steps below.

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.

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.

Fixed

The same cell is announced as: Recycling, Mill Lane, Tuesday. Column, row, then value.

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}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>
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}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

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

Before
<tr><td>Street</td><td>Recycling</td></tr>
<tr><td>Mill Lane</td><td>Tuesday</td></tr>
After
<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

Before
<table>
  <tr><td>Street</td><td>Recycling</td></tr>
  <tr><td>Mill Lane</td><td>Tuesday</td></tr>
</table>
After
<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

Before
<table>
  <tr><td><img src="logo.svg" alt="Logo"></td><td>Opening hours</td></tr>
</table>
After
<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:

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.

Scan your own page for the 64 checks Websight does run

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.