Ch. 5 · HTML & CSS

HTML Data Tables and Header Relationships

How th, scope, caption and headers/id tell a screen reader which headers describe a cell, with a two-level pricing table worked through.

~7 min readadvancedupdated Oct 7, 2026

A sighted reader looks at the number 42 in the middle of a grid, glances up to the column heading, glances left to the row heading, and knows what 42 means. A screen reader user can’t glance. They move cell by cell with table navigation keys (Ctrl+Alt+arrows in NVDA and JAWS, VO+arrows in VoiceOver), and the only way the software can tell them what 42 means is to read out the headers associated with that cell. If the markup doesn’t declare those headers, the user hears “42” and nothing else.

That’s the whole job of table semantics: making the relationship between a data cell and its headers explicit in the markup instead of implicit in the layout. Interviewers ask about it because it separates people who know <table> exists from people who know what the browser builds from it.

What the browser does with a table

When the browser parses a <table>, it builds an accessibility tree with roles for the table, its rows, its column headers, row headers and cells. A <th> becomes a header. A <td> becomes a cell. When a screen reader enters a new column, it reads the column header first; when it enters a new row, it reads the row header first. Both behaviours depend on the browser knowing which <th> elements govern which <td> elements.

For a simple grid with one header row, browsers can guess: a <th> in the first row is probably a column header. That guessing is the reason many tables “work” without scope. It also breaks quickly, because the heuristics differ between browsers and fall apart once a table has headers in both the first row and the first column, or headers spanning several columns.

A styled first row is not a header at all. This is the mistake interviewers listen for:

<table>
  <tr class="header-row"><td>Topic</td><td>Score</td></tr>
  <tr><td>React</td><td>8 / 10</td></tr>
</table>
HTML

It renders as a bold row above the data if .header-row makes it bold, and the accessibility tree contains four plain cells. Nothing tells assistive technology that “Topic” describes “React”. The fix isn’t a CSS change; it’s a different element.

A simple table, marked up properly

Here is the same table with the relationships declared:

<table>
  <caption>Practice results, week of 5 October</caption>
  <thead>
    <tr>
      <th scope="col">Topic</th>
      <th scope="col">Score</th>
      <th scope="col">Time taken</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">React</th>
      <td>8 / 10</td>
      <td>12 min</td>
    </tr>
    <tr>
      <th scope="row">TypeScript</th>
      <td>6 / 10</td>
      <td>15 min</td>
    </tr>
  </tbody>
</table>
HTML

Each choice is doing something specific.

<caption> must be the first child of <table>. It is the table’s accessible name, so a screen reader announces “Practice results, week of 5 October, table with 3 columns and 3 rows” on entry, and the table shows up by that name in the list of tables some readers offer. A heading above the table is not the same thing: it isn’t programmatically tied to the table. If you need a visible heading for design reasons, you can point at it with aria-labelledby on the <table>, but <caption> is simpler and styles fine with caption-side.

scope="col" says “this header describes the cells below it in this column”. scope="row" says “this header describes the cells to its right in this row”. The topic names are row headers, so they’re <th> too, not <td> in bold. Moving to the “6 / 10” cell from “8 / 10” now announces something like “TypeScript, 6 / 10”, and moving across to “15 min” announces “Time taken, 15 min”.

<thead> and <tbody> don’t create header relationships on their own. They group rows, which lets the browser repeat the header row on each printed page and gives you styling hooks. They’re worth including, but scope is what carries the meaning.

The rendered result looks like an ordinary table: the caption centred above it by default, header cells bold and centred (the user-agent default for <th>), data cells left aligned. You’ll usually set th[scope="row"] { text-align: start; } because centred row headers look odd.

When scope stops being enough

scope handles one header per direction. Real tables sometimes have more. Consider a plan comparison where the columns are grouped:

Monthly: Price Monthly: Seats Yearly: Price Yearly: Seats
Starter 9 3 90 3
Team 29 10 290 10

In HTML you’d build the top band with colspan:

<table>
  <caption>Plans by billing period</caption>
  <thead>
    <tr>
      <td></td>
      <th scope="colgroup" colspan="2">Monthly</th>
      <th scope="colgroup" colspan="2">Yearly</th>
    </tr>
    <tr>
      <td></td>
      <th scope="col">Price (USD)</th>
      <th scope="col">Seats</th>
      <th scope="col">Price (USD)</th>
      <th scope="col">Seats</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Starter</th>
      <td>9</td><td>3</td><td>90</td><td>3</td>
    </tr>
    <tr>
      <th scope="row">Team</th>
      <td>29</td><td>10</td><td>290</td><td>10</td>
    </tr>
  </tbody>
</table>
HTML

scope="colgroup" tells the browser that “Monthly” covers the columns it spans. With this, the “290” cell should be announced with “Team”, “Yearly” and “Price (USD)”. The empty top-left corner is a <td>, not an empty <th>, because an empty header gives the screen reader nothing to read and some readers announce it as “blank”.

Support for colgroup and rowgroup scopes is decent in current browser and screen reader pairs but not perfect, which is why the W3C tables tutorial also documents the explicit approach: give every header an id and list them on each data cell.

<th id="yearly" colspan="2">Yearly</th>
<th id="yearly-price">Price (USD)</th>
<!-- ... -->
<th id="team">Team</th>
<td headers="team yearly yearly-price">290</td>
HTML

The headers attribute takes a space-separated list of id values, and the screen reader reads them in that order. It is precise and it is tedious: every data cell needs it, and a single typo silently drops a header. Use it when the table is genuinely irregular (headers in the middle of the table, cells governed by headers that aren’t in their row or column), and test it. For a table this regular, scope is enough; a common interview follow-up is “why not always use headers?”, and the honest answer is maintenance cost and the risk of stale ids.

A better answer to a very complex table is often to split it. Two tables, “Monthly plans” and “Yearly plans”, each with one header row, are easier for everyone to read, sighted users included.

Layout tables and the role attribute

Tables used for layout (email templates are the last common case) should not expose table semantics, because a screen reader announcing “table with 2 columns” around a newsletter’s sidebar is noise. Add role="presentation" to the <table> so the browser drops the table roles, and don’t use <th>, <caption> or scope inside it. For web pages, use grid or flexbox for layout and keep <table> for data.

The reverse problem comes from CSS. Applying display: block or display: flex to a <table>, <tr> or <td> to make it responsive has, historically, caused some browsers to drop the table semantics from the accessibility tree. Chrome and Firefox have mostly fixed this, Safari has been inconsistent across versions, and the safe pattern for wide tables doesn’t need it anyway:

<div class="table-scroll" role="region" aria-labelledby="plans-caption" tabindex="0">
  <table>
    <caption id="plans-caption">Plans by billing period</caption>
    <!-- ... -->
  </table>
</div>
HTML
.table-scroll {
  overflow-x: auto;
}
.table-scroll:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
CSS

On a 360px phone the table keeps its natural width and the wrapper scrolls horizontally. tabindex="0" lets keyboard users focus the wrapper and scroll it with the arrow keys; without it, a keyboard user can’t reach the hidden columns unless the table contains links. The region role and label give that focus stop a name, so the screen reader says “Plans by billing period, region” rather than an unnamed group.

Checking your table

Automated checkers catch missing <th> elements and empty headers, but not wrong relationships. The useful checks are manual:

  • Open the browser’s accessibility inspector (Chrome DevTools, Elements, Accessibility pane) and select a data cell. Its computed properties should list the headers you expect.
  • Run VoiceOver (Cmd+F5 on macOS) or NVDA on Windows, enter the table, and move with the table navigation keys. Move across a row and down a column and listen for the right header each time.
  • Zoom to 400% or narrow the viewport to 320 CSS pixels and confirm every column can still be reached, by mouse and by keyboard.

Interview practice

You’re shown a results dashboard built from <div> elements with CSS grid: a header row of column names and rows of student scores. The team wants it “made accessible” with the least change. What do you recommend?

Answer

Convert it to a real <table>. The data is tabular (every value is defined by a student and a measure), and a table gives screen reader users header announcements and table navigation for free. ARIA has role="table", role="row", role="columnheader", role="rowheader" and role="cell", and applying them to the existing divs is technically possible, but you’d be rebuilding by hand what <table> provides, with more ways to get it wrong (every row needs the role, every cell needs the role, and nothing is checked). Use <caption> for the dashboard’s name, <th scope="col"> for the measures and <th scope="row"> for each student’s name. CSS grid layout can stay on the visual side if needed, but test in Safari before applying display changes to table elements, and use a scrolling wrapper for narrow screens. Finally, verify with a screen reader that moving to a score announces both the student and the measure.

Keep going

More in HTML & CSS

read ✓HTML & CSS · easy

HTML Buttons Versus Links for Actions

Links go somewhere and buttons do something. What each element gives you for free, and what breaks when a div or an a tag fakes the other.

~6 min readread →
read ✓HTML & CSS · easy

HTML Headings and Document Navigation

How screen reader users jump between headings, why levels should follow structure rather than font size, and how to fix skipped levels.

~6 min readread →
read ✓HTML & CSS · easy

HTML Landmarks and Meaningful Page Structure

How header, nav, main, aside, footer and labelled sections become landmarks that screen reader users jump between, with a div-soup page rebuilt.

~6 min readread →
esc