Data table headers must stay within their table scope only
Last updated:
Related Guides
Data table headers must associate only to cells in the same table
Intro:
Every data cell must be programmatically tied to its header cells within the same table. Use proper table structure and header associations so assistive technologies announce the right context for each cell.
Why It Matters
Screen reader users navigate tables by moving cell to cell. If headers are not correctly associated, they hear the wrong labels or none at all, making the data unusable.
People with low vision who rely on screen reader output for context, and users with cognitive disabilities who depend on clear relationships, are especially impacted.
Correct relationships also help all users when tables reflow on small screens or are exported.
Common Causes
- Using
<td>for headers instead of<th>. - Omitting scope on simple tables, or using it incorrectly.
- Using headers/id for complex tables but referencing IDs that don’t exist or live outside the same table.
- Duplicate or missing id values on
<th>. - Complex spanning (rowspan/colspan) without matching scope or headers mappings.
- Treating layout tables as data tables (or vice versa).
How to Fix
- Confirm it’s a data table
- If the table is for layout only, replace it with CSS or add
role="presentation"and remove header semantics.
- If the table is for layout only, replace it with CSS or add
- Use semantic structure
- Include
<caption>to name the table. - Use
<thead>,<tbody>, and<tfoot>when helpful for structure.
- Include
- Mark headers correctly (simple tables)
- Put column headers in
<th scope="col">. - Put row headers in
<th scope="row">. - For grouped headers, use
scope="colgroup"orscope="rowgroup"on the header that spans multiple columns/rows.
- Put column headers in
- Map complex tables with headers/id
- Give each header cell a unique id.
- On each data cell (
<td>), setheaders="id1 id2 ..."listing all relevant header ids in reading order. - Ensure every id referenced by headers is inside the same
<table>element and is unique.
- Handle spans correctly
- When using colspan/rowspan, confirm the scope or headers mappings reflect the visual grouping.
- Verify relationships
- No headers attribute may reference an element outside the current table.
- Avoid empty header cells; if a header is visually empty, confirm screen reader output still conveys meaning.
- Align with WCAG
- Primary success criterion: WCAG 2.2 — 1.3.1 Info and Relationships.
How to Test
Keyboard check
- Tab through interactive elements inside the table to confirm focus order follows the visual order.
- If table cells contain controls, ensure focus moves predictably cell by cell.
Screen reader check
- NVDA (Firefox/Chrome): Move to the table (t). Use Ctrl+Alt+Arrow keys to traverse. Confirm each data cell announces the correct column and row header(s).
- JAWS (Chrome/Edge): Use table navigation (Alt+Ctrl+Arrows). Verify header announcements match what’s visible.
- VoiceOver (macOS): Use the Rotor for tables or VO+Command+Arrows. Ensure header names are read for each cell.
Mobile/touch check
- VoiceOver (iOS) or TalkBack (Android): Navigate the table by swiping or using table rotor options. Confirm the same correct headers are announced.
Code validation check
- Confirm all
<th>used as headers exist and have scope (simple) or id (complex) as needed. - For complex tables, ensure each <td>[headers] references valid ids within the same table.
const tables = document.querySelectorAll('table');
tables.forEach(table => {
const ids = new Set([...table.querySelectorAll('th[id]')].map(h => h.id));
table.querySelectorAll('td[headers]').forEach(td => {
td.getAttribute('headers').split(\s+).forEach(ref => {
if (!ids.has(ref)) console.warn('Missing or cross-table header id:', ref, td);
});
});
});Good Example
A simple data table using scope for row and column headers.
<table>
<caption>Quarterly Revenue (USD)</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
<th scope="col">Q4</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>240,000</td>
<td>260,500</td>
<td>255,300</td>
<td>270,100</td>
</tr>
<tr>
<th scope="row">South</th>
<td>190,200</td>
<td>205,900</td>
<td>212,400</td>
<td>219,800</td>
</tr>
</tbody>
</table>Bad Example
Missing <th> and invalid headers references that point outside the table.
<table>
<caption>Shipping Costs</caption>
<tr>
<td>Zone</td>
<td id="ext-q1">Standard</td> <!-- This id belongs to another table or page section -->
<td>Express</td>
</tr>
<tr>
<td>Domestic</td>
<td headers="ext-q1">6.50</td> <!-- References an id not in this table -->
<td headers="missing-id">12.00</td> <!-- References a non-existent id -->
</tr>
</table>Quick Checklist
- Use
<th>for headers;<td>for data. - For simple tables, set scope="col"/"row" on each
<th>. - For complex tables, use unique id on
<th>and map<td headers>accordingly. - Never reference header ids outside the same table.
- Verify colspan/rowspan are reflected in scope or headers mappings.
- Include a concise
<caption>describing the table. - Test with a screen reader to confirm correct header announcements.