AccessScanRun a free scan

Guide

Accessible Data Tables: th, scope, caption, and Responsive Patterns

Tables are one of the few HTML structures where the markup itself carries meaning. Get the semantics right and a screen reader can announce "row 3, column Price, $48" as the user arrows through cells. Get it wrong and the same table collapses into an undifferentiated wall of numbers. Accessible data tables come down to a handful of elements used correctly: th, scope, caption, and sound header-to-cell associations.

This guide is for developers shipping real tables: pricing grids, dashboards, transaction histories, comparison charts. We cover the correct element structure, both header-association strategies, why layout tables are a defect, and how to keep wide tables usable on small screens without quietly destroying the semantics screen readers depend on.

Start with correct table structure

A data table needs more than <table> and <td>. The minimum semantic skeleton uses <caption> for the title, <thead> and <tbody> to group sections, and <th> for every header cell. Marking up headers as <td> with bold styling is the single most common failure: it looks like a header but is announced as ordinary data.

Here is a correctly structured table:

<table> <caption>Q2 server costs by region</caption> <thead> <tr> <th scope="col">Region</th> <th scope="col">Instances</th> <th scope="col">Monthly cost</th> </tr> </thead> <tbody> <tr> <th scope="row">EU-West</th> <td>12</td> <td>$1,440</td> </tr> </tbody> </table>

Notice the first cell of each body row is also a <th scope="row">. Row headers are easy to forget, but they are what let a screen reader announce "EU-West" as context when the user lands on the $1,440 cell.

Use scope for simple header associations

The scope attribute tells assistive technology which cells a header governs. It takes four values, and two of them cover almost every real table:

  • scope="col" — the header applies to all cells below it in the column.
  • scope="row" — the header applies to all cells across that row.
  • scope="colgroup" / scope="rowgroup" — for headers that span multiple columns or rows, used with colspan/rowspan.

For any table with a single header row and an optional header column, scope is all you need. It is explicit, survives copy-paste, and is far harder to break than the alternative. Automated checkers, including AccessScan's free scanner at run a quick scan, will flag <th> elements missing a scope value, because ambiguous headers are a frequent real-world defect.

Use headers and id for complex tables

When a table has multiple levels of headers, spanning header cells, or data cells that relate to headers that are not directly above or beside them, scope runs out of road. That is where the headers/id pattern earns its keep: every header cell gets a unique id, and every data cell lists the relevant header ids in a space-separated headers attribute.

<th id="q1" colspan="2">Q1</th> paired with <td headers="q1 revenue">$2.1M</td> tells the screen reader this cell belongs to both the "Q1" group header and the "Revenue" row header, even across a spanning cell. It is verbose and must be maintained by hand, so reserve it for genuinely complex tables. Do not mix scope and headers on the same table, and split a truly complicated grid into two simpler tables where you can.

Never use tables for layout

Using <table> to position a page's columns or cards is a legacy practice that fails accessibility on its own terms. Screen readers enter "table mode" and announce row and column coordinates for content that has no tabular meaning, producing noise like "row 2 of 4, column 1 of 3" around your sidebar. It also defeats responsive design and bloats the DOM.

CSS Grid and Flexbox handle every layout job a table once did, with proper source order and reflow. The one widely accepted exception is HTML email, where client CSS support is still poor; in that narrow case add role="presentation" to the layout table so assistive technology skips its structure. For everything on the web, layout tables are a defect. This matters for compliance too: under the European Accessibility Act, the baseline is WCAG 2.2 Level A and AA via EN 301 549, and misused table semantics undermine 1.3.1 Info and Relationships.

Make wide tables responsive without losing semantics

Wide tables overflow small screens, and the instinct is to restyle them with CSS. The trap: applying display: block, flex, or grid to <table>, <tr>, or <td> strips their implicit ARIA roles, so the table disappears from the accessibility tree as a table. Visually it looks like stacked cards; to a screen reader it becomes a run of disconnected text.

Two patterns keep semantics intact:

  • Scrollable container: wrap the table in a <div role="region" aria-label="Q2 server costs" tabindex="0"> with overflow-x: auto. The tabindex makes the region keyboard-scrollable, the accessible name tells screen reader users what they are scrolling, and the table keeps every native role.
  • Stacked rows with restored roles: if you must collapse to cards, reapply roles explicitly with role="table", role="row", role="cell", and role="columnheader", and expose each value's label via CSS ::before content sourced from a data-label attribute.

The scroll-container pattern is simpler and the one to reach for first. Whichever you choose, verify it with a screen reader, not just a visual check; the accessibility checklist is a good prompt for the manual passes automated tools cannot cover. Also confirm any interactive controls inside cells, such as sort buttons, meet the WCAG 2.2 target-size minimum of at least 24 by 24 CSS pixels (2.5.8, AA) and keep a visible focus indicator.

A quick pre-ship checklist

  • Every header cell is a <th>, never a styled <td>.
  • Single-level tables use scope="col" and scope="row"; complex ones use headers/id.
  • Each table has a <caption> or an equivalent accessible name.
  • No <table> is used for layout (HTML email aside).
  • Wide tables scroll inside a focusable, labeled region rather than being restyled into role-stripping display values.
  • You have navigated the table with an actual screen reader, not only an automated scan.

Check your site against AccessScan

See your issues ranked by impact in seconds — free.

Run a free accessibility scan

FAQ

Do I always need a <caption> on a data table?

It is strongly recommended. The <caption> gives the table a programmatically associated, on-screen title that screen readers announce when a user enters the table, so they know its purpose before navigating cells. If you genuinely cannot show a visible title, use aria-label or aria-labelledby on the <table> instead, but a real <caption> is the most robust choice and benefits sighted users too.

Is scope or headers/id better for associating headers?

Use scope for simple tables with a single row and/or column of headers. It is less verbose and less error-prone. Reserve the headers/id pattern for complex tables with multiple header levels, spanning headers, or cells that relate to non-adjacent headers. Do not mix the two strategies on the same table, and never use either on layout tables.

Are layout tables ever acceptable?

On the web, no. Use CSS Grid or Flexbox for layout. The one common exception is HTML email, where client support for modern CSS is poor and table-based layout is still standard practice. In that case add role="presentation" to the layout table so assistive technology ignores its structure.

How do I make a wide table responsive without breaking accessibility?

Prefer wrapping the table in a focusable scroll container (tabindex="0" with role="region" and an accessible name) so keyboard users can scroll it. Avoid CSS that changes display to block/flex on table elements unless you restore semantics with ARIA roles, because display:block strips the table's implicit grid roles from the accessibility tree.

More guides