Complex tables with multi-level headers

A data table works only when each cell can be traced back to the headers that explain it. A sighted reader does this by glancing up the column and across the row; a screen reader user relies entirely on the programmatic relationships in the markup. In a simple grid a single scope="col" and scope="row" are enough. But the moment a table grows a second tier of headers — a column header that spans several sub-columns, a header that labels a whole block of rows, or a grid where every data cell answers to both a row heading and a column heading — those simple cues stop being sufficient, and the relationships silently fall apart.

When that happens, a screen reader announces a number with no idea what it measures: “1,204” with no “Q2”, no “Europe”, no “Revenue”. This lesson works through the three defects behind most complex-table failures. Each is fixed with native HTML — scope for the straightforward cases, and explicit headers / id wiring for the genuinely irregular ones — not with bolt-on ARIA.

What you’ll learn

How to tie every data cell to both its row and its column header in a two-dimensional table; how to associate spanned or merged header cells with the sub-headers and data beneath them using colspan, scope="colgroup", and headers/id; and how to mark a header that labels a group of rows with scope="rowgroup" so the group’s name is announced with every row inside it.

Standards this lesson maps to
Standard Criterion Level What it requires
WCAG 2.2 1.3.1 Info and Relationships A The header-to-cell relationships in a table are conveyed in the markup, so a data cell’s row and column headers are programmatically determinable — not implied by visual position alone.
WCAG 2.2 1.3.2 Meaningful Sequence A The reading order of the table (cell by cell, header before data) makes sense when linearised by assistive technology.
EN 301 549 9.1.3.1 (incorporates WCAG 1.3.1) European harmonised standard; references the WCAG Level A table-structure requirements.
Section 508 502.3 / 504 (incorporates WCAG A & AA) US federal ICT must meet WCAG 2.0 Level A and AA, including correct data-table header association.
ADA Title II WCAG 2.1 AA (DOJ rule) AA US state/local government web content must conform to WCAG 2.1 AA, which carries 1.3.1 forward unchanged.

The three problems we’ll fix

Each card below isolates one common complex-table defect. For every issue you get a plain-language statement of the problem, a Bad example (shown as escaped, non-running code so it can’t affect this page), a Good example, the copyable Code, and an ordered fix.

Data cells not tied to both row and column headers

WCAG 2.2 · 1.3.1 A EN 301 549 Section 508 ADA Title II

In a two-dimensional table every data cell is defined by two things at once — the column it sits under and the row it sits in. A common failure marks up only the top row of headers (or, worse, uses plain <td> for the first column) so the row labels are never exposed as headers. A screen reader can then announce the column but not the row: the user hears “1,204” under “Q2” with no idea it’s the Europe figure. The fix is to promote the first cell of every row to a <th> and give each header the right scopecol for the top row, row for the leftmost column — so each data cell inherits one of each.

Bad

The row labels are plain <td> cells and the column headers have no scope. The grid means nothing once it is read cell by cell: data cells have no row association at all (1.3.1).

bad-twodim.html
<table>
  <tr>
    <td></td><td>Q1</td><td>Q2</td>
  </tr>
  <tr>
    <td>Europe</td><td>980</td><td>1,204</td>
  </tr>
  <tr>
    <td>Americas</td><td>1,310</td><td>1,455</td>
  </tr>
</table>

Good

The first row uses scope="col" and the first cell of every body row is a <th scope="row">. Now each data cell has exactly one column header and one row header, so “1,204” is announced as “Europe, Q2, 1,204”.

good-twodim.html
<table>
  <caption>Net revenue by region (£000s)</caption>
  <thead>
    <tr>
      <td></td>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Europe</th>
      <td>980</td><td>1,204</td>
    </tr>
    <tr>
      <th scope="row">Americas</th>
      <td>1,310</td><td>1,455</td>
    </tr>
  </tbody>
</table>

Code

When the grid is irregular — split cells, headers that don’t line up — drop scope and wire each data cell explicitly with headers listing the id of every header that governs it. This always wins, because it leaves nothing to be inferred from position.

twodim-headers-id.html
<tr>
  <td></td>
  <th id="q1">Q1</th>
  <th id="q2">Q2</th>
</tr>
<tr>
  <th id="eu">Europe</th>
  <td headers="eu q1">980</td>
  <td headers="eu q2">1,204</td>
</tr>

How to fix

  1. Make the first cell of every data row a <th scope="row">, not a <td>.
  2. Mark every top-row label as <th scope="col">.
  3. Confirm each data cell now resolves to exactly one row header and one column header in the accessibility tree.
  4. If the grid is irregular and scope can’t resolve cleanly, give each header an id and point each data cell at them with headers.

Spanned or merged header cells not associated

WCAG 2.2 · 1.3.1 A EN 301 549 Section 508

Multi-level headers introduce a header cell that spans several columns — for example a “2024” heading sitting above paired “Sales” and “Returns” sub-columns. A bare colspan draws the merge visually but says nothing about which sub-headers and data cells belong under it. Screen readers then associate a number with the sub-header (“Sales”) but lose the spanning header (“2024”), so the user can’t tell the 2024 figure from the 2025 one. The spanning header needs scope="colgroup", and where the association is ambiguous the data cells should list both levels via headers.

Bad

The spanning header uses colspan but no scope, and the sub-headers don’t reference it. The two year groups are only distinguishable by eye (1.3.1).

bad-spanned.html
<table>
  <tr>
    <td></td>
    <td colspan="2">2024</td>
    <td colspan="2">2025</td>
  </tr>
  <tr>
    <td></td>
    <td>Sales</td><td>Returns</td>
    <td>Sales</td><td>Returns</td>
  </tr>
  <tr>
    <td>North</td>
    <td>820</td><td>31</td>
    <td>910</td><td>28</td>
  </tr>
</table>

Good

The spanning header is a <th colspan="2" scope="colgroup"> and each sub-header is a <th scope="col">. The colgroup scope makes “2024” apply to both columns beneath it, so a cell is announced as “North, 2024, Sales, 820”.

good-spanned.html
<table>
  <caption>Units sold and returned by year</caption>
  <thead>
    <tr>
      <td></td>
      <th colspan="2" scope="colgroup">2024</th>
      <th colspan="2" scope="colgroup">2025</th>
    </tr>
    <tr>
      <td></td>
      <th scope="col">Sales</th><th scope="col">Returns</th>
      <th scope="col">Sales</th><th scope="col">Returns</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>820</td><td>31</td>
      <td>910</td><td>28</td>
    </tr>
  </tbody>
</table>

Code

Support for scope="colgroup" varies by screen reader. For mission-critical tables, or when sub-headers repeat (“Sales” twice), make the association explicit: give every header an id and list both levels on each data cell.

spanned-headers-id.html
<th id="y24" colspan="2">2024</th>
<th id="y24-sales">Sales</th>
<th id="y24-ret">Returns</th>
...
<th id="north">North</th>
<td headers="north y24 y24-sales">820</td>
<td headers="north y24 y24-ret">31</td>

How to fix

  1. Make every header a real <th>; never leave a spanning label as a styled <td>.
  2. Give the cell that spans columns scope="colgroup" and each sub-header scope="col".
  3. Where a screen reader may not honour colgroup, or sub-headers repeat, wire the data cells with headers listing both the spanning header and the sub-header.
  4. Test with a screen reader that the spanning level is actually announced — not just visible.

Header that labels a group of rows

WCAG 2.2 · 1.3.1 A EN 301 549 Section 508

Many tables are organised into blocks of rows under a category heading — “Europe” covering France, Germany and Spain, then “Americas” covering its countries. That category is often dropped into its own full-width row, or styled as bold text, with no programmatic link to the rows it introduces. A screen reader user moving down the table hears the country names but never the category, so they lose track of which block they’re in. The category cell needs to be a <th scope="rowgroup"> so its label carries down every row of the group.

Bad

The category is a styled <td> spanning the row. It has no header role and no scope, so it never associates with the rows below it (1.3.1).

bad-rowgroup.html
<table>
  <tr>
    <td colspan="2"><strong>Europe</strong></td>
  </tr>
  <tr>
    <td>France</td><td>980</td>
  </tr>
  <tr>
    <td>Germany</td><td>1,120</td>
  </tr>
</table>

Good

Each block is a <tbody>, and the category cell is a <th scope="rowgroup">. Its label now applies to every row in the group, so a figure is announced as “Europe, France, 980”.

good-rowgroup.html
<table>
  <caption>Revenue by country (£000s)</caption>
  <thead>
    <tr><th scope="col">Country</th><th scope="col">Revenue</th></tr>
  </thead>
  <tbody>
    <tr>
      <th scope="rowgroup" colspan="2">Europe</th>
    </tr>
    <tr>
      <th scope="row">France</th><td>980</td>
    </tr>
    <tr>
      <th scope="row">Germany</th><td>1,120</td>
    </tr>
  </tbody>
</table>

Code

Where scope="rowgroup" support is shaky, give the rowgroup header an id and reference it from each row’s headers alongside the row’s own label — making the group association explicit and screen-reader-independent.

rowgroup-headers-id.html
<tr>
  <th id="europe" colspan="2">Europe</th>
</tr>
<tr>
  <th id="france" headers="europe">France</th>
  <td headers="europe france rev">980</td>
</tr>

How to fix

  1. Wrap each block of related rows in its own <tbody>.
  2. Make the category cell a <th scope="rowgroup">, not a styled <td>.
  3. Keep each row’s own label as a <th scope="row"> so cells get both the group and the row name.
  4. If rowgroup support is unreliable for your audience, fall back to headers/id and reference the group header from each row.

Recap

  • In a two-dimensional table, every data cell must answer to both a row header and a column header. Use scope="col" and scope="row" for a regular grid; switch to headers/id when the layout is irregular (1.3.1).
  • When a header cell spans several columns, give it scope="colgroup" (or wire it with headers/id) so the sub-headers and data beneath it inherit the spanned label (1.3.1).
  • When a header labels a block of rows, give it scope="rowgroup" so its name is announced with every row in that group (1.3.1).

For any table too irregular for scope to resolve cleanly, fall back to explicit headers/id: it always wins. The same markup satisfies WCAG, EN 301 549, Section 508, and ADA Title II at once.