( Perceivable / WCAG 1.3.1 )
Table cell headers attribute references a missing id
What is this issue?
A <td> element’s headers attribute contains a space-separated list of ids, and at least one of those ids doesn’t resolve to any element currently in the DOM (the tree structure the browser builds from your HTML, which assistive technology reads instead of the visual layout). This is a table specifically using the explicit headers/id association method, most common in complex tables with multi-level or merged headers where the simpler scope attribute can’t fully express the relationship.
The mismatch is almost always accidental rather than intentional: a header cell’s id gets changed during a refactor or a CMS field is renamed, and every <td> still pointing at the old id is left silently broken, since HTML doesn’t raise any visible error for a dangling reference.
Why does this matter?
In a complex data table, headers is what lets a screen reader announce the full context for a single cell, something like “Q3, Revenue, $2.4M,” by resolving each id in the list back to its header text and reading it before the cell’s own content. When one of those ids doesn’t match anything, that specific piece of context is simply missing from the announcement, with no fallback and no indication to the user that something went wrong.
The user doesn’t hear an error message; they hear a cell with less context than the cell next to it, and have no way to know whether that’s intentional or a bug. In a financial table, a broken headers reference on one cell might mean the difference between hearing “Q3, Revenue, $2.4M” and hearing only “$2.4M,” a number with no column, no row, and no way to tell what it represents without backtracking through the whole table by hand.
Who is affected?
- Screen reader users: hear a data cell with incomplete or entirely missing header context, and have to navigate back through the table manually to reconstruct what a number or value actually represents.
- Cognitive disabilities: users relying on the automatic header announcement to avoid re-deriving row and column context for every cell lose that shortcut exactly on the cells where a broken reference occurs.
What users experience
Bridget uses NVDA on Windows to review a quarterly finance report built as a complex table with merged, multi-level column headers using headers/id. Most cells announce cleanly, “Q3, Revenue, $2.4M,” but one column was recently relabeled from “Revenue” to “Net Revenue,” and the developer updated the visible <th> text without updating its id, leaving every <td headers="revenue"> in that column pointing at an id that no longer exists. NVDA reads those cells as just the raw dollar figure with no header context at all, so Bridget has to move her cursor back up to the header row and manually count columns to confirm which figures belong to that one relabeled column, a step none of the surrounding, correctly-labeled cells require.
How do I fix it?
Make sure every id listed in a <td>’s headers attribute matches the id of a real <th> currently in the page. This works because headers is purely a lookup: a screen reader resolves each listed id to an element and reads that element’s text as the cell’s context, so once the id points at the right <th>, the announcement resolves correctly again.
Whenever you rename a header’s visible text, check for its id in the same change: the two commonly drift apart because the visible text is what content teams see and edit, while the id sits invisibly in the markup with no obvious signal that anything downstream depends on it. If your table markup is generated by a script or CMS template, generate id values from something stable (a column key, not the display label) so relabeling the header text doesn’t silently break every cell referencing it.
Code Examples
<table>
<tr>
<th id="revenue">Net Revenue</th>
</tr>
<tr>
<td headers="revenue-old">$2.4M</td>
</tr>
</table><table>
<tr>
<th id="revenue">Net Revenue</th>
</tr>
<tr>
<td headers="revenue">$2.4M</td>
</tr>
</table>The header cell’s id didn’t change; only the stale revenue-old value in the <td>’s headers attribute was corrected to match it. That one-word fix restores the lookup a screen reader relies on to announce “Net Revenue, $2.4M” instead of just “$2.4M.”
Common Mistakes
Mistake: “I renamed the header text, so the association should update automatically.” A <th>’s visible text and its id are two completely independent pieces of markup: changing one never changes the other. Renaming a header’s display text is safe on its own, but if any <td> references that header by id, the id itself must stay the same (or every reference to it must be updated at the same time).
Mistake: “The table still looks correct visually, so the headers references must be fine.” A broken headers id produces no visible symptom at all: the table renders identically whether the reference resolves or not, since headers has no effect on layout or appearance. The only way to catch a broken reference is to check the markup directly or test with a screen reader; visual review alone will never reveal it.
Mistake: “Using scope instead of headers avoids this problem entirely, so I should always prefer it.” scope="col" or scope="row" is simpler and has nothing to reference or break, but it only works for tables where each cell relates to exactly one row header and one column header. A table with merged, multi-level headers genuinely needs headers/id to express relationships scope can’t; the fix for this rule is keeping the ids correct, not avoiding the attribute altogether.
How RedFlag Detects This
Automated: axe-core rule, runs on every scan. RedFlag calls axe-core’s td-headers-attr rule as part of every scan, restricted to the WCAG 2.0/2.1/2.2 A and AA rule set. The rule inspects every <td> with a headers attribute and confirms every id in that space-separated list resolves to an existing element on the page.
False negative: axe-core confirms every id exists somewhere on the page; it can’t confirm the id belongs to the correct header for that cell. A headers="wrong-id" value that happens to match a real but unrelated element elsewhere in the page passes the automated check even though the announced context is wrong. False positive: none typical for this check, since confirming whether a referenced id resolves to an existing element is a binary lookup. Manual step: for any complex table using headers, read a sample of cells with a screen reader and confirm the announced header context genuinely matches the row and column each cell sits in, not just that some header text is announced.
Manual Testing
- Open the page in Chrome or Firefox with NVDA or JAWS running on Windows.
- Navigate into a complex data table using table-navigation commands (NVDA/JAWS: Ctrl+Alt+Arrow keys to move cell by cell).
- Listen to the announcement for each cell: it should state the relevant row and column header text before the cell’s own value, for example “Net Revenue, $2.4M.”
- If a cell announces only its raw value with no header context, inspect its
headersattribute in browser dev tools and confirm every id it lists matches a real<th>id on the page. - Repeat for cells throughout the table, not just the first row or column, since a broken reference is often isolated to one relabeled column rather than the whole table.
Related WCAG Success Criteria
1.3.1 Info and Relationships: Information, structure, and relationships conveyed through presentation must also be available programmatically. A headers attribute pointing at a nonexistent id is a direct break in that programmatic relationship: the row/column association a sighted user reads visually from the table’s layout no longer reaches a screen reader user at all for that cell.
Related Issues
Table header has no associated data cells is the reverse direction of the same broken-association family: instead of a <td> pointing at a header that doesn’t exist, it’s a <th> that no <td> ever points back to.
Table header scope value is invalid covers the simpler-table equivalent of this failure, where scope rather than headers/id carries an invalid value.
Table is missing header cells is the more basic failure underneath this whole family: a table with no <th> elements at all, before any headers/id association can even be attempted.
Complex data table is missing a caption commonly co-occurs with headers/id tables, since both are hallmarks of genuinely complex tables that need the extra structural care simple tables don’t.
References
- W3C Understanding 1.3.1: Info and Relationships
- MDN: The headers attribute
- WebAIM: Creating Accessible Tables - Complex Tables
Frequently asked questions
Do I need the headers attribute if my table already uses scope?
No. The scope attribute is enough for most simple tables with a single row or column of headers, and headers is only needed for complex tables where a cell relates to more than one header in a way scope alone cannot express, such as a merged multi-level header.
What happens if a headers attribute lists more than one id?
A screen reader announces every header the ids resolve to, in the order listed, before reading the cell content, which is exactly how a cell with both a row header and a column header should be announced. Every id in the list still needs to match a real element or the whole announcement for that cell breaks.
Does the id referenced by headers have to belong to a th element specifically?
No, technically headers can reference any element with a matching id, though it should point to the actual header cell for the association to make sense to a reader. In practice it should always point at the th holding the relevant column or row label.
Can a broken headers reference happen even if I never manually typed the id wrong?
Yes, this is a very common outcome of automated table generation or a CMS field rename. If a column header text changes and a build step regenerates its id from that text, every td headers attribute still pointing at the old id breaks silently.
Does this rule also check whether the headers value points at the correct header, not just an existing one?
No. Automated checking can only confirm the referenced id exists somewhere on the page, not that it is the right header for that specific cell. A headers attribute pointing at an unrelated but real id passes the automated check while still announcing the wrong context.