Who it affects
A parent using a screen reader to find their child's swimming gala result lands on 1st and hears no event name, because the headers attribute points at an id that was renamed when the table was rebuilt.
Someone with a cognitive disability using a screen reader to keep their place hears the wrong heading for a cell and trusts it, because nothing sounds broken.
The club volunteer who maintains the page pasted the table from a spreadsheet export that generated ids, then tidied the ids away without knowing they were referenced.
Broken versus fixed
Both examples are live and rendered in a sandboxed frame. The broken one is deliberately broken.
The 1st cell points at freestyle and r1. There is no element with id freestyle, and r1 is a td, not a header. A screen reader announces: 1st.
Announced: Amira K, 50 m freestyle, 1st. Every id in each headers list is a th in this table.
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:#eef}caption{text-align:left;font-weight:bold;padding-bottom:6px}</style>
<table>
<caption>Under 12 results, spring gala</caption>
<tr><th id="name">Swimmer</th><th id="free">50 m freestyle</th><th id="back">50 m backstroke</th></tr>
<tr><td id="r1" headers="name">Amira K</td><td headers="r1 freestyle">1st</td><td headers="r1 bak">3rd</td></tr>
<tr><td id="r2" headers="name">Tom B</td><td headers="r2 freestyle">2nd</td><td headers="r2 bak">1st</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:#eef}caption{text-align:left;font-weight:bold;padding-bottom:6px}</style>
<table>
<caption>Under 12 results, spring gala</caption>
<tr><th id="name">Swimmer</th><th id="free">50 m freestyle</th><th id="back">50 m backstroke</th></tr>
<tr><th id="r1" scope="row">Amira K</th><td headers="r1 free">1st</td><td headers="r1 back">3rd</td></tr>
<tr><th id="r2" scope="row">Tom B</th><td headers="r2 free">2nd</td><td headers="r2 back">1st</td></tr>
</table>
Test it by hand in two minutes
- Right click a cell, choose Inspect, and copy each id from its headers attribute. Press Ctrl+F in the Elements panel and search for id="that-value". It should land on a th inside the same table.
- With a screen reader running, use table navigation (Ctrl+Alt+arrow keys in NVDA, Ctrl+Option+arrow keys in VoiceOver) and listen to which headings are read before each value.
- Search the page for duplicate ids. A headers attribute picks up whichever element the browser finds first.
- If the table is a simple grid, delete the headers attributes and use scope on the th cells instead. Simpler markup breaks less often.
The fix
<tr><th id="name">Swimmer</th><th id="free">50 m freestyle</th></tr>
<tr><td id="r1">Amira K</td><td headers="r1 freestyle">1st</td></tr>
<tr><th id="name">Swimmer</th><th id="free">50 m freestyle</th></tr>
<tr><th id="r1" scope="row">Amira K</th><td headers="r1 free">1st</td></tr>
Only use headers when scope cannot describe the relationship, for example with grouped column headings. Every id listed must sit on a th inside the same table.
Two levels of column heading
<tr><th id="date" rowspan="2">Date</th><th id="home" colspan="2">Home</th></tr>
<tr><th id="won">Won</th><th id="lost">Lost</th></tr>
<tr><th id="d1">3 May</th><td headers="home-won">4</td><td headers="home-lost">1</td></tr>
<tr><th id="date" rowspan="2">Date</th><th id="home" colspan="2">Home</th></tr>
<tr><th id="won">Won</th><th id="lost">Lost</th></tr>
<tr><th id="d1">3 May</th><td headers="d1 home won">4</td><td headers="d1 home lost">1</td></tr>
List every header that applies, separated by spaces, in the order you want them read: row, group, then column.
Simple table that does not need headers at all
<tr><th id="a">Session</th><th id="b">Lanes</th></tr>
<tr><td headers="a">Monday</td><td headers="b">4</td></tr>
<tr><th scope="col">Session</th><th scope="col">Lanes</th></tr>
<tr><td>Monday</td><td>4</td></tr>
One header row, one header column: scope does it. Fewer ids to keep in sync.
Common mistakes
- Pointing headers at a td that looks like a header because it is bold.
- Keeping ids from a spreadsheet export like cell-A1 in the headers lists after the cells were renamed.
- Listing an id from a different table on the same page that happens to have the same heading text.
- Typing the heading text instead of its id, for example headers="Swimmer".
- Mixing headers and scope on the same cells in ways that disagree.
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 reads every headers attribute and checks that each id resolves to a th in the same table. It cannot tell whether the right th is referenced, only that something valid is. Listen to the table with a screen reader to confirm the headings make sense. 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.
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.
A
ids used by ARIA and labels must be unique
duplicate-id-aria
aria-labelledby, aria-describedby, aria-controls and the label for attribute all work by id.