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 withcolspan/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">withoverflow-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", androle="columnheader", and expose each value's label via CSS::beforecontent sourced from adata-labelattribute.
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"andscope="row"; complex ones useheaders/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
displayvalues. - You have navigated the table with an actual screen reader, not only an automated scan.