Tables Rule

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. It is the manual way to connect cells when scope cannot do the job, for example in a table with two levels of column headings. Each id in the list must belong to a th in the same table. An id that is missing, mistyped or sitting on a td gives the screen reader nothing, or the wrong thing, to read.

Websight reports this as Table cell headers attributes must refer to other <th> elements in the same table

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.

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.

Fixed

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

  1. 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.
  2. 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.
  3. Search the page for duplicate ids. A headers attribute picks up whichever element the browser finds first.
  4. 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

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

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

Before
<tr><th id="a">Session</th><th id="b">Lanes</th></tr>
<tr><td headers="a">Monday</td><td headers="b">4</td></tr>
After
<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:

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.

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.