A data table works because each cell is anchored to a row heading and a column
heading. Take those anchors away and the meaning goes with them: a screen reader
reading the grid cell by cell announces “48,000” with nothing to say which quarter
or which metric it belongs to. The good news is that HTML already has everything you
need to keep the anchors in place — <th>, scope, and
<caption>. The defects in this lesson are almost always a matter of
using the wrong element or leaving an attribute off, not of missing some advanced
technique.
This lesson works through the four defects behind the majority of real-world table
failures. Each one is small to fix and uses plain HTML you already have — the wins
come from giving the browser and assistive technology the structure they expect.
What you’ll learn
How to mark header cells as real <th> elements;
how to set scope="col" and scope="row" (and
headers/id for complex grids) so each cell is announced
with the right headings; how to name a table with a <caption>;
and why you should never expose a layout table as data.
Standards this lesson maps to
Standard
Criterion
Level
What it requires
WCAG 2.2
1.3.1 Info and Relationships
A
The row/column relationships of a table are conveyed in the markup, not by visual position alone.
WCAG 2.2
1.3.2 Meaningful Sequence
A
The reading order of table content is correct and programmatically determinable.
EN 301 549
9.1.3.1 / 9.1.3.2 (incorporates WCAG)
—
European harmonised standard; references the WCAG A/AA set including table structure criteria.
Section 508
502.3 / 504 (incorporates WCAG A & AA)
—
US federal ICT must meet WCAG 2.0 Level A and AA, including data-table headers.
ADA Title II
WCAG 2.1 AA (DOJ rule)
AA
US state/local government web content must conform to WCAG 2.1 AA.
The four problems we’ll fix
Each card below isolates one common 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 harm this page), a Good
example, the copyable Code, and an ordered fix.
No header cells
WCAG 2.2 · 1.3.1AEN 301 549Section 508
A table built entirely from <td>
cells has no headers, even if the top row looks like one because it’s
bold. Visual weight is not structure: nothing in the markup says “this cell labels
the column below it”. A screen reader reading the grid cell by cell announces only
the raw values — “Q1, 32,000, 18,000, Q2, 48,000, 21,000” — with no labels paired
to them, so the user can’t tell which figure is revenue and which is cost. Marking
the label cells as <th> turns that flat stream back into a grid
the user can navigate (1.3.1).
Bad
The first row only looks like a header — it’s styled, but it’s still
<td>. No cell is exposed as a header, so values are
announced with nothing to label them.
Style the <th> however you like — semantics and appearance
are independent. There is no need to add <b>: a header cell
is bold and centred by default, and you can override that with CSS.
th-styling.html
th { text-align: left; font-weight: 600; }
<!-- The cell is still a header to assistive tech, -->
<!-- regardless of how it is painted on screen. -->
<th>Revenue</th>
How to fix
Mark every label cell as <th> — both the column labels
along the top and any row labels down the side.
Put the header row inside <thead> and the data rows
inside <tbody> so the structure is explicit.
Drop the <b> wrappers — a header cell is emphasised by
default, and styling is a CSS concern, not a structural one.
Check the accessibility tree: each value cell should report the header it
belongs to, e.g. “Revenue, 48,000”.
Headers without scope
WCAG 2.2 · 1.3.1A1.3.2AEN 301 549
In a table with both column headers and row
headers, a bare <th> is ambiguous: assistive technology has to
guess whether it labels its column or its row. Browsers apply heuristics, but they
are unreliable the moment the shape is anything other than a simple single-header
grid, so cells get paired with the wrong heading or with none. The
scope attribute removes the guesswork by stating the direction
outright — scope="col" for a column header, scope="row"
for a row header — so every data cell is announced with exactly the right labels
(1.3.1, 1.3.2).
Bad
This grid has headers across the top and down the side, but no
scope. Which heading each <th> governs is left
to the screen reader to infer.
When a single cell is governed by more than one header on the same axis — a
complex table with spanning or multi-level headers — scope isn’t
enough. Give each header an id and list them in the cell’s
headers attribute.
Add scope="col" to every column header and
scope="row" to every row header.
Don’t rely on browser heuristics — set scope even when a
simple table seems to read correctly without it.
For complex tables with spanning or multiple header levels, give each
header an id and reference them from each data cell’s
headers attribute.
Test with a screen reader’s table navigation: moving into any cell should
announce both its column and its row heading.
No <caption>
WCAG 2.2 · 1.3.1AEN 301 549Section 508
A caption is the table’s accessible name — the one
line that says what the whole grid is about. Without it, a screen reader user who
lands on the table, or who pulls up a list of tables on the page to choose between
them, hears only “table, three columns, four rows” with no idea whether it holds
opening hours, prices, or a fixture list. A <caption> as the
first child of the <table> is read out before the contents and
ties the table to its purpose; it’s visible to everyone and far more robust than a
nearby heading the table doesn’t actually reference (1.3.1).
Bad
The table’s purpose lives only in a separate heading above it. Nothing in the
table points to that heading, so the table itself has no accessible name.
A <caption> is the first child of the table, so it becomes
the table’s accessible name and is announced before the data. It’s visible to
sighted users too.
If a visible caption truly can’t fit the design, give the table an
aria-label or point aria-labelledby at an existing
heading. A real <caption> is still preferred — it helps
everyone, not just assistive-tech users.
table-name-alternatives.html
<!-- Preferred: a visible caption -->
<table><caption>Quarterly results</caption> … </table>
<!-- Or name the table from an existing heading -->
<h2 id="results-h">Quarterly results</h2>
<table aria-labelledby="results-h"> … </table>
How to fix
Add a <caption> as the very first child of every data
<table>, naming what it contains.
Keep the caption short and specific — “Opening hours”, “Quarterly results” —
so it reads well as the table’s name.
If the design can’t show a visible caption, name the table with
aria-label or aria-labelledby instead, but prefer
a real caption.
Don’t rely on a nearby heading alone — unless the table references it, the
table has no accessible name.
Layout table exposed as data
WCAG 2.2 · 1.3.1A1.3.2AEN 301 549
The mirror image of the other defects: using
<table> — sometimes with <th> — purely to
position things on the page. There’s no data and no real row/column relationship,
but the markup claims there is. A screen reader announces “table, two columns, one
row”, reads phantom header cells, and forces table-navigation mode on content that
is just a logo beside a heading. It adds confusion and noise, and because cells are
read in source order the visual sequence can come out wrong too (1.3.2). Layout is a
job for CSS; table semantics should be reserved for genuine tabular data.
Bad
A table is being used to sit a logo next to a heading. It carries
<th> and is exposed as a data table, so AT announces a grid
that has no data in it.
bad-layout-table.html
<table>
<tr>
<th><img src="logo.svg" alt="Acme"></th>
<td><h2>Welcome to Acme</h2></td>
</tr>
</table>
Good
The same layout with CSS. There’s no fake grid: the logo and heading are
ordinary flow content placed side by side with a flex (or grid) container.
If you genuinely can’t replace a layout table right now, neutralise its
semantics with role="presentation" and remove any
<th>, <caption>, or scope.
This is a stopgap — CSS layout is the real fix.
presentation-fallback.html
<table role="presentation">
<tr>
<td><img src="logo.svg" alt="Acme"></td>
<td><h2>Welcome to Acme</h2></td>
</tr>
</table>
<!-- No th, no caption, no scope on a layout table. -->
How to fix
Lay pages out with CSS — flex or grid — not with
<table> elements.
Reserve <table>, <th>,
<caption>, and scope for content that is
genuinely tabular data.
If a legacy layout table must stay for now, add
role="presentation" and strip any header markup from it as a
temporary measure.
Check the source order matches the visual order so the reading sequence is
correct (1.3.2).
Recap
Every data table needs the same three things. Run this checklist over any table
before you ship it:
Caption — a <caption> as the first child
of the table, naming what it contains (1.3.1).
th — every header cell is a
<th>, not a styled <td> (1.3.1).
scope — scope="col" on column headers and
scope="row" on row headers, so each cell is announced with its
headings; use headers/id for complex grids
(1.3.1, 1.3.2).
And one rule in the negative: never use a <table>
for visual layout. Reserve table semantics for real data, and the same markup
satisfies WCAG, EN 301 549, Section 508, and ADA Title II at once.