Data Tables · Development · WCAG

Accessible Data Tables: The One Markup Mistake That Turns Your Table Into Noise

  • Data Tables
  • Development
  • WCAG

A screen reader user opens your pricing page, lands on the comparison table, and hears this: "Forty-nine. Ninety-nine. Two-hundred. Nine."

No labels. No column names. No idea whether "forty-nine" is a price, a user count, or a storage limit. Just numbers, floating in space, disconnected from everything that gives them meaning.

The table that only works if you can see it

Picture a SaaS founder who just shipped a shiny new pricing page. Three tiers, a comparison grid, checkmarks down the side. It looks clean. It looks modern. A designer built it as a <div> grid with careful CSS alignment, because that gave them pixel-perfect control over spacing and hover states.

Visually, it's flawless. Every sighted visitor instantly understands it — they scan across the "Pro" column, down to the "Storage" row, and land on "500GB" without even thinking about it. That association happens automatically, just from position on the screen.

A screen reader user doesn't get that for free. There is no "automatically" for someone navigating by ear. If the structure that connects "500GB" to "Storage" and to "Pro" isn't explicitly built into the markup, it simply doesn't exist for them. The table becomes a flat list of numbers with no relationships at all.

This is, by a wide margin, the single most common way real tables fail for screen reader users — and it's almost always fixable with markup most developers already know how to write.

Why sighted scanning doesn't translate

Here's the core of the problem. A sighted user reads a table two-dimensionally: eyes sweep across a row, then down a column, cross-referencing instantly. You don't consciously calculate that the cell under "Storage" and across from "Pro" means "the Pro plan includes this much storage" — your brain just does it, because the grid layout does the work.

A screen reader has no eyes to sweep with. It reads cell by cell, in source order, and it can only announce the relationships that are programmatically determinable — meaning encoded in the markup itself, not just implied by column alignment or bold text.

This is exactly what 1.3.1 Info and Relationships (Level A) requires. It's one of the foundational WCAG success criteria, and it says that structure, relationships, and meaning conveyed visually must also be available in code. For a table, that means: which cells are headers, which are data, and how each data cell maps back to its header.

Bold text or shaded rows might make a header cell look different to a sighted user. But visual styling alone tells a screen reader nothing. Without real header markup, "Price" in bold and "$49" underneath it are just two unrelated pieces of text sitting near each other.

The fix: real <th> cells with scope

For the vast majority of tables — including pricing tables, comparison charts, and transaction histories — the fix is small:

  • Use a real <table> element, not a <div> grid, whenever the content is genuinely tabular data.
  • Mark header cells as <th scope="col"> for column headers and <th scope="row"> for row headers, instead of using <td> for everything.
  • Give the table a <caption> (or an accessible name via aria-labelledby), so a screen reader user landed on it knows what they're looking at before diving into the cells.

That scope attribute is doing the heavy lifting. It tells assistive technology "this header applies to the whole column below it" or "this header applies to the whole row beside it." With that in place, a screen reader can announce "Storage, Pro plan: 500GB" instead of just "500GB" — because it now knows exactly which header each cell belongs to.

The Stat: WebAIM's WebAIM Million evaluation of the top 1,000,000 home pages has repeatedly found that around 95-96% have detectable WCAG 2 failures in recent years — and structural issues like missing table headers are exactly the kind of "invisible to sighted users, blocking to everyone else" failure that keeps showing up. (WebAIM: The WebAIM Million)

WCAG 2 failure rate across the WebAIM Million WebAIM Million: WCAG 2 Failure Rate Missing table headers, a top failure type Have WCAG failures 95.9% No failures detected 4.1% Source: WebAIM, "The WebAIM Million" annual accessibility evaluation

When you need more than scope

Simple tables need simple markup — scope="col" and scope="row" will carry you through almost every pricing table, feature comparison, or transaction list you'll ever build.

But some tables are genuinely complex: merged header cells spanning multiple columns, multi-level headers (a "Q1" header sitting above three sub-columns for "Jan," "Feb," "Mar"), or nested groupings. In those cases, scope alone can become ambiguous — a cell might logically belong to more than one header in a way scope can't express cleanly.

That's where the headers and id attribute pairing comes in. Each <th> gets a unique id, and each <td> lists every header id it relates to in a headers attribute. This gives explicit, unambiguous associations no matter how tangled the table gets.

Treat this as a last resort, though — not a default. It's verbose, easy to get wrong, and best reserved for tables that truly can't be simplified. Before reaching for headers/id, ask whether the table could be restructured, or split into two simpler tables, instead.

Before vs. After: What the Screen Reader Actually Hears

Table Element Broken Markup (div grid or <td>-only) Accessible Markup (real <table> + <th scope>)
Column header Bold <div> text, styled only <th scope="col">Storage</th>
Row header Plain text cell <th scope="row">Pro Plan</th>
Data cell <td>500GB</td> announced as "500GB" <td>500GB</td> announced as "Storage, Pro Plan: 500GB"
Table title Heading floating above the layout, unlinked <caption> or aria-labelledby, announced on entry
Complex/merged headers No relationship possible headers/id pairing, explicit and unambiguous
WCAG status Fails 1.3.1 Info and Relationships (Level A) Passes 1.3.1 Info and Relationships (Level A)

This isn't just a screen reader problem

Even setting aside assistive technology entirely, this is a legal exposure issue. UsableNet's annual ADA Digital Accessibility Lawsuit Report has tracked several thousand federal ADA website lawsuits filed per year in recent years, with plaintiffs' firms increasingly sending pre-suit demand letters before ever filing suit. Retail and ecommerce sites — the exact category most likely to have pricing tables, product comparison grids, and order history tables — are consistently reported among the most-sued industries for web accessibility.

And this problem doesn't stay contained to one table. Once a team builds one pricing table as a <div> grid because "it looked easier to style," that pattern tends to get copied into the next comparison chart, the next dashboard, the next transaction history. One unlabeled table becomes a site-wide pattern of inaccessible data.

Meanwhile, over 1 billion people worldwide — roughly 16% of the global population — live with some form of disability (World Health Organization), and roughly 1 in 4 U.S. adults live with some type of disability (CDC). A meaningful share of your traffic is trying to compare your pricing tiers with a screen reader, and a table with no headers simply doesn't work for them.

Get your tables actually checked, not just eyeballed

You can't catch a missing scope attribute by looking at a table — it renders identically whether the markup is right or wrong. That's exactly why this failure is so common: it's invisible until someone actually tests with a screen reader or an automated accessibility audit.

If your pricing page, comparison charts, or transaction history tables were built with a design-first, code-second mindset, there's a good chance they're missing the header structure that makes them usable. Get your product, pricing, and data tables audited and find out exactly which tables are silently failing 1.3.1 Info and Relationships — and get a concrete fix, not just a flagged issue. Start your audit.