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.1AEN 301 549Section 508ADA 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 scope — col 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).
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”.
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.
Make the first cell of every data row a <th scope="row">,
not a <td>.
Mark every top-row label as <th scope="col">.
Confirm each data cell now resolves to exactly one row header and one column
header in the accessibility tree.
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.1AEN 301 549Section 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).
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.
Make every header a real <th>; never leave a spanning
label as a styled <td>.
Give the cell that spans columns scope="colgroup" and each
sub-header scope="col".
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.
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.1AEN 301 549Section 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).
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”.
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.
Wrap each block of related rows in its own <tbody>.
Make the category cell a <th scope="rowgroup">, not a
styled <td>.
Keep each row’s own label as a <th scope="row"> so cells
get both the group and the row name.
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.