Who it affects
Someone using a screen reader to book the village hall jumps between the three tables on the page by pressing T and hears table, 5 rows, 3 columns, three times. No names, so no way to tell the bookings table from the hire charges.
Someone with a memory impairment reading a long page scrolls back up and cannot see at a glance what each table is, because the title is styled like a cell and blends in.
A sighted keyboard user with a screen reader for support hears the title read as the first cell of the first row, then hears the Day heading announced as part of row two.
Broken versus fixed
Both examples are live and rendered in a sandboxed frame. The broken one is deliberately broken.
Arriving at the table a screen reader says: table with 5 rows and 3 columns. The title is just another cell, and the Day header is on row two.
Announced: table, Hall bookings, week of 3 November, 4 rows and 3 columns. The listener knows what the table is before reading a cell.
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}th{background:#fee}</style>
<table>
<tr><td colspan="3"><strong>Hall bookings, week of 3 November</strong></td></tr>
<tr><th scope="col">Day</th><th scope="col">Morning</th><th scope="col">Evening</th></tr>
<tr><th scope="row">Monday</th><td>Toddler group</td><td>Yoga</td></tr>
<tr><th scope="row">Tuesday</th><td>Free</td><td>Parish council</td></tr>
<tr><th scope="row">Wednesday</th><td>Art club</td><td>Badminton</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}th{background:#fee}caption{text-align:left;font-weight:bold;padding:6px 0}</style>
<table>
<caption>Hall bookings, week of 3 November</caption>
<tr><th scope="col">Day</th><th scope="col">Morning</th><th scope="col">Evening</th></tr>
<tr><th scope="row">Monday</th><td>Toddler group</td><td>Yoga</td></tr>
<tr><th scope="row">Tuesday</th><td>Free</td><td>Parish council</td></tr>
<tr><th scope="row">Wednesday</th><td>Art club</td><td>Badminton</td></tr>
</table>
Test it by hand in two minutes
- Look at the first row. If one cell stretches across the whole table and holds a title, it is a fake caption.
- Right click the title and choose Inspect. It should be a caption element, not a td or th with colspan.
- With VoiceOver, open the rotor (Ctrl+Option+U) and choose Tables: each table should be listed by its caption. With NVDA, press T to jump between tables and listen for the caption.
- Check the first real row uses th cells. A fake caption often hides that the headers underneath are plain td.
The fix
<table>
<tr><td colspan="3"><strong>Hall bookings, week of 3 November</strong></td></tr>
<tr><th scope="col">Day</th><th scope="col">Morning</th><th scope="col">Evening</th></tr>
<table>
<caption>Hall bookings, week of 3 November</caption>
<tr><th scope="col">Day</th><th scope="col">Morning</th><th scope="col">Evening</th></tr>
The caption goes straight after the opening table tag. Style it however you like; it does not have to look like a caption.
Heading already sits above the table
<h3>Hall bookings</h3>
<table>
<tr><td colspan="3">Hall bookings</td></tr>
<h3 id="bookings">Hall bookings</h3>
<table aria-labelledby="bookings">
If you do not want to repeat the heading, point the table at it with aria-labelledby. A caption is still the simplest option.
Caption that must not be visible
<caption style="display:none">Hire charges</caption>
<caption class="visually-hidden">Hire charges</caption>
display:none hides it from screen readers too. A visually-hidden class (clip it to 1px off screen) keeps it for them.
Common mistakes
- Putting the title in a th with colspan, which is just as fake.
- Using the summary attribute, which was dropped in HTML5 and most screen readers ignore.
- Hiding the caption with display:none, which removes it for everyone.
- Writing the title as a paragraph straight after the table instead of a caption.
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 looks for a table with no caption element whose first or last row is a single cell spanning the table and holding text. It cannot tell whether that cell was meant as a title or is a genuine data cell. This check is tagged experimental in axe-core. 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
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.
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.