An accessible data table needs three things your page builder probably didn’t add: header cells marked with <th> instead of <td>, a scope attribute saying whether each header labels a column or a row, and a <caption> naming the table. Add those and the pricing or hours table flagged in your audit passes — same design, same page, roughly ten minutes of work. This article gives you the exact markup for the three tables small-business sites actually have, plus the two traps (page builders and pasted documents) that break tables silently.
Why did the audit flag a table that looks fine?
Because the problem is invisible on screen. A sighted visitor sees your price grid and instantly connects “$65” to the “Large dog” column and the “Bath and brush” row. A screen reader can only make that connection if the code declares it.
When the markup is right, WebAIM’s guide to accessible data tables explains, screen reader users “can navigate through data tables one cell at a time, and they will hear the column and row headers spoken to them.” When it’s wrong — every cell a plain <td> — the same user hears a stream of disconnected fragments: “Bath and brush. $45. $65.” Which price is which? There’s no way to tell without counting cells and holding the whole grid in memory.
That’s the failure your auditor logged, usually against WCAG 1.3.1 Info and Relationships, which requires that structure shown visually is also present in code. The fix is not a redesign. It’s swapping a handful of tags.
The three-part fix: th, scope, and caption
The full repair kit is three pieces of standard HTML, straight from the W3C’s tables tutorial:
<th>for every header cell. The W3C is blunt: “Header cells must be marked up with<th>, and data cells with<td>to make tables accessible.” Bold styling on a<td>does nothing for a screen reader.scopeon each<th>.scope="col"means “I label the column below me”;scope="row"means “I label the row beside me.” The W3C’s tutorial applies it whenever header direction could be ambiguous.<caption>naming the table. Per the same W3C tutorial, “a caption identifies the overall topic of a table and is useful in most situations” — screen reader users often jump straight from table to table, and the caption is what gets announced on arrival. WebAIM adds that it “must be the first thing after the opening<table>tag.”
Which pattern you need depends on which table you have:
| Your table | Headers run | Pattern |
|---|---|---|
| Pricing / rate card | Across the top | <th scope="col"> |
| Business hours | Down the left side | <th scope="row"> |
| Service comparison | Both directions | Both, one per <th> |
Fix 1: The pricing table
A pricing table’s headers run across the top, so every header gets scope="col". Here’s a complete example you can adapt — swap in your own services and prices:
<table>
<caption>Grooming prices</caption>
<thead>
<tr>
<th scope="col">Service</th>
<th scope="col">Small dog</th>
<th scope="col">Large dog</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bath and brush</td>
<td>$45</td>
<td>$65</td>
</tr>
<tr>
<td>Full groom</td>
<td>$75</td>
<td>$110</td>
</tr>
</tbody>
</table>
Now a screen reader user arrowing into the last cell hears “Large dog, $110” — the column header travels with the price. One more rule worth stealing from WebAIM: “table headers should never be empty,” including the top-left corner cell. Give it a real label like “Service.”
Fix 2: The business hours table
Hours tables are the mirror image: the headers are the days, running down the left side. That means scope="row" — the variant page builders almost never produce on their own.
<table>
<caption>Business hours</caption>
<tbody>
<tr>
<th scope="row">Monday–Friday</th>
<td>9 a.m.–6 p.m.</td>
</tr>
<tr>
<th scope="row">Saturday</th>
<td>10 a.m.–4 p.m.</td>
</tr>
<tr>
<th scope="row">Sunday</th>
<td>Closed</td>
</tr>
</tbody>
</table>
Without scope="row", “Closed” is just a floating word. With it, the day is announced first. Bonus: real HTML hours are also what search crawlers read — one of the quiet overlaps between accessibility and SEO.
Fix 3: The service comparison table
Comparison tables — your packages across the top, features down the side — have headers in both directions, so they use both scopes:
<table>
<caption>Website care plans compared</caption>
<thead>
<tr>
<th scope="col">Feature</th>
<th scope="col">Basic</th>
<th scope="col">Pro</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monthly backups</th>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<th scope="row">Priority support</th>
<td>No</td>
<td>Yes</td>
</tr>
</tbody>
</table>
A user parked on any “Yes” now hears both coordinates: “Priority support, Pro, Yes.” One caution from WebAIM: some screen readers “still do not fully support complex tables with spanned or multiple levels of row and/or column headers” — so avoid merged cells and stacked header rows. If your comparison grid needs a merged cell to make sense, split it into two simple tables instead.
The page-builder trap: tables that aren’t tables
Here’s the part that surprises owners: your builder’s “table” may not be a table at all. Some page-builder and theme pricing modules render a grid of styled <div> elements — zero rows, columns, or headers in the code. Others place a designed table as an image, which a screen reader can’t enter at all. Both fail the audit no matter how carefully you wrote the content.
Even honest table blocks cut corners. WordPress’s core table block outputs real <th> cells once you switch on its header section, but a Gutenberg accessibility issue open on GitHub documents that those header cells ship without scope attributes and that the block’s caption isn’t rendered as a true <caption> element. Squarespace goes further: it has no native table block, and Squarespace’s own help center points you to building the table in HTML inside a code block — which, silver lining, means you can paste the corrected markup above directly.
The 30-second check for any platform: right-click your table, choose Inspect, and look at the tags. Real <table>, <th>, scope? You’re in good shape. A wall of <div class="row">? That’s the finding. Platform-specific workarounds live in our WordPress and Squarespace guides.
The copy-paste trap: Google Docs strips your headers
The other silent killer is pasting a table from a document. Google Docs lets you pin a header row so it repeats across pages, but that’s styling, not structure — Stanford’s accessibility team puts it plainly: “Google Docs lacks the full set of features needed to create accessible tables for screen-reader users.” Paste that table into your site editor and the header row typically arrives as ordinary bold <td> cells.
So the pricing sheet your assistant maintains in Docs, pasted into the site every season, quietly regenerates the same audit finding each time. The fix is a habit, not a tool: after every paste, either re-apply the header row with your editor’s table controls, or open the code view and change the first row’s td tags to th with the right scope. It’s the same class of invisible problem that makes automated scans miss issues a human tester catches immediately.
What about phones? Responsive tables without breaking them
Wide tables get cramped on a phone, and the two popular “fixes” are where good tables go to die. The rules:
Horizontal scrolling is allowed. WCAG 1.4.10 Reflow generally bans two-direction scrolling at narrow widths, but the W3C’s understanding document carves out an exception: “Data tables and grids have a two-dimensional relationship between column and row headers and their data cells. This success criterion therefore has exceptions for data tables and grids from needing to display without scrolling in the direction of text.” Wrap the table in a container with overflow-x: auto, plus tabindex="0", role="region", and a label, so keyboard users can reach and scroll it.
CSS that flattens the table is not. The trendy stacked-card pattern sets table elements to display: block so rows pile up vertically. Accessibility consultant Adrian Roselli tested exactly this and found that “as soon as I changed the table styles to display: block, screen readers no longer consider this to be a table” — the browser stops exposing rows and headers entirely — and he notes that slapping role="table" on it “does not turn it back into a table.” His follow-up posts show how to bolt the semantics back on with a full set of ARIA roles if you want stacking; if that’s beyond your web person’s comfort zone, the scrolling wrapper is the safe default.
Or sidestep the problem. A three-row hours table can simply become a short list or a few lines of text on mobile. Nothing in WCAG says hours must be a table — it says whatever structure you use must be real.
The 10-minute fix checklist
- Find every table on your money pages: pricing, hours, comparisons, shipping rates, class schedules.
- Inspect each one. Real
<table>markup, or divs and images pretending? Fakes go on the rebuild list. - Tag the headers. First row and/or first column become
<th>— and never leave a header cell empty. - Add scope.
scope="col"for top headers,scope="row"for side headers, both in comparison tables. - Add a caption as the first element inside the table, naming what it shows.
- Shrink your browser window to phone width. Confirm the table scrolls or reflows without hiding data, and that no
display: blockCSS has flattened it. - Re-test with a scanner, then spend five minutes arrow-keying through the table in a free screen reader like NVDA or VoiceOver.
Tables are among the most fixable findings on an audit report — the markup is standardized, the patterns are decades old, and nothing about your design has to change. They’re also rarely the only finding. If a table got flagged, the same habits that caught it will catch unlabeled forms, missing alt text, and contrast failures too. Run our free accessibility scan to see what else is on your list — and if you’d rather hand that list to someone who fixes source code for a living, that’s exactly what our remediation service does.