Accessible data tables

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.1 A EN 301 549 Section 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.

bad-no-headers.html
<table>
  <tr><td><b>Quarter</b></td><td><b>Revenue</b></td><td><b>Cost</b></td></tr>
  <tr><td>Q1</td><td>32,000</td><td>18,000</td></tr>
  <tr><td>Q2</td><td>48,000</td><td>21,000</td></tr>
</table>

Good

The label cells are real <th> elements. The header row sits in <thead> and the data in <tbody>, so the browser exposes a proper header/data structure.

good-th.html
<table>
  <thead>
    <tr><th>Quarter</th><th>Revenue</th><th>Cost</th></tr>
  </thead>
  <tbody>
    <tr><th>Q1</th><td>32,000</td><td>18,000</td></tr>
    <tr><th>Q2</th><td>48,000</td><td>21,000</td></tr>
  </tbody>
</table>

Code

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

  1. Mark every label cell as <th> — both the column labels along the top and any row labels down the side.
  2. Put the header row inside <thead> and the data rows inside <tbody> so the structure is explicit.
  3. Drop the <b> wrappers — a header cell is emphasised by default, and styling is a CSS concern, not a structural one.
  4. 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.1 A 1.3.2 A EN 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.

bad-no-scope.html
<tr><th></th><th>Mon</th><th>Tue</th></tr>
<tr><th>Open</th><td>09:00</td><td>09:00</td></tr>
<tr><th>Close</th><td>17:00</td><td>20:00</td></tr>

Good

Column headers get scope="col" and row headers get scope="row". Now “20:00” is announced as “Tue, Close, 20:00” — both axes, no guessing.

good-scope.html
<tr><td></td><th scope="col">Mon</th><th scope="col">Tue</th></tr>
<tr><th scope="row">Open</th><td>09:00</td><td>09:00</td></tr>
<tr><th scope="row">Close</th><td>17:00</td><td>20:00</td></tr>

Code

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.

headers-ids.html
<tr>
  <th id="h-q2" scope="col">Q2</th>
  <th id="h-rev" scope="col">Revenue</th>
</tr>
<tr>
  <td headers="h-q2 h-rev">48,000</td>
</tr>

How to fix

  1. Add scope="col" to every column header and scope="row" to every row header.
  2. Don’t rely on browser heuristics — set scope even when a simple table seems to read correctly without it.
  3. For complex tables with spanning or multiple header levels, give each header an id and reference them from each data cell’s headers attribute.
  4. 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.1 A EN 301 549 Section 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.

bad-no-caption.html
<h2>Opening hours</h2>
<table>
  <tr><th scope="col">Day</th><th scope="col">Hours</th></tr>
  <tr><th scope="row">Mon</th><td>09:00–17:00</td></tr>
</table>

Good

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.

good-caption.html
<table>
  <caption>Opening hours</caption>
  <tr><th scope="col">Day</th><th scope="col">Hours</th></tr>
  <tr><th scope="row">Mon</th><td>09:00–17:00</td></tr>
</table>

Code

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

  1. Add a <caption> as the very first child of every data <table>, naming what it contains.
  2. Keep the caption short and specific — “Opening hours”, “Quarterly results” — so it reads well as the table’s name.
  3. If the design can’t show a visible caption, name the table with aria-label or aria-labelledby instead, but prefer a real caption.
  4. 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.1 A 1.3.2 A EN 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.

good-css-layout.html
<div class="masthead">
  <img src="logo.svg" alt="Acme">
  <h2>Welcome to Acme</h2>
</div>

<style>
  .masthead { display: flex; align-items: center; gap: 1rem; }
</style>

Code

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

  1. Lay pages out with CSS — flex or grid — not with <table> elements.
  2. Reserve <table>, <th>, <caption>, and scope for content that is genuinely tabular data.
  3. If a legacy layout table must stay for now, add role="presentation" and strip any header markup from it as a temporary measure.
  4. 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).
  • scopescope="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.