A designer ships a pricing table they are genuinely proud of. Three columns, Basic, Pro, and Enterprise, a merged banner spanning the top of two columns that reads "Save 20% Annually," bold price points, a clean row of feature checkmarks underneath. It looks sharp in the Figma file and it looks sharp in the browser. Nobody in the review meeting flags a single problem, because nobody in the review meeting is using a screen reader.
Here is what a screen reader actually announces when it hits that same table, cell by cell, with no header association: "49 dollars." "99 dollars." "249 dollars." "Unlimited." "5 users." "Priority." Numbers and words with no row context and no column context, read out in the same flat sequence a spreadsheet of random numbers would get. The visual structure that makes the table instantly readable to a sighted user, the alignment, the color-coded headers, the merged banner, carries zero information to anyone who cannot see it.
The Stat: Only 19% of the data tables found across the top 1,000,000 home pages had valid, properly structured data table markup. (Source: WebAIM Million 2026)
What the Original Markup Actually Looked Like
The table in this teardown is a realistic composite, the kind of two-tier header structure that shows up constantly in pricing pages, comparison charts, and plan matrices. Visually it has two header concepts stacked: a top banner spanning two of the three columns, and a row of plan names underneath. Structurally, in the original markup, it was built from a plain <table> with no <thead>, no <th> elements at all, just <td> for every cell including the ones that were visually styled to look like headers.
That distinction, <td> versus <th>, is not cosmetic. A <th> element is a real semantic header. Browsers and assistive technology use it to build a map of which headers apply to which data cells. A <td> styled with bold text and a background color looks identical to a sighted user and is structurally invisible to a screen reader. This is the single most common root cause behind tables that "look fine" and "read broken."
Fix One: Real Headers, Not Styled Cells
The first fix is the most basic and the most frequently skipped: every cell that functions as a header, the plan names, the banner text, needs to actually be a <th> element, not a <td> with header-looking CSS. This alone gets a screen reader from announcing an undifferentiated wall of text to at least knowing which cells are structural headers versus which are data.
It is not sufficient on its own, though, especially for a table with two tiers of headers like this one. A <th scope="col"> on the plan-name row tells a screen reader "everything below me in this column belongs to me." That works cleanly for a single-tier table. This table has a second header layer, the "Save 20% Annually" banner spanning two columns, and scope alone cannot express a relationship that spans across a merged region cleanly for every cell beneath it.
Fix Two: The headers and id Technique for Two-Tier Headers
For tables with more than one row of headers, the W3C's own guidance points to a different technique: give every header cell a unique id, and give every data cell a headers attribute listing every header id that applies to it, separated by spaces. It is more verbose to write by hand than scope, but it is unambiguous. A screen reader reading a cell in the "Pro" column under the "Save 20% Annually" banner announces both headers before the value, in order, every time, regardless of how many tiers the table's headers actually have.
This is the difference that matters most for exactly this kind of table. scope="col" handles a simple single-header-row table well. The moment a table has a merged banner row above the plan names, scope alone leaves the relationship between the banner and the individual columns underneath it ambiguous, something the HTML specification itself calls out as a known limitation for tables with two or more logical header rows. The headers/id technique is built specifically to remove that ambiguity.
Fix Three: A Caption, Not Just a Heading Above the Table
The pricing table in the original page had an <h2> above it that read "Choose Your Plan," visually acting as the table's title. A heading before a table is not programmatically connected to the table itself, though. A <caption> element, nested inside the <table> tag, is. When a screen reader user navigates directly to the table (many screen readers let users jump table to table on a page), a caption is announced immediately as part of the table, giving them context before they hear a single data cell. A preceding heading, without a caption, is easy to miss entirely if the user's navigation method skips straight to the table.
What This Sounds Like, Before and After
Before the fix, landing on the first data cell in the "Pro" column and moving through the row sounds roughly like: "99 dollars." "25 users." "Priority." No column name, no plan name, no idea what any of it refers to without backing all the way up to re-read the visual layout, which a screen reader user cannot do by glancing.
After the fix, the same cell announces: "Save 20% Annually, Pro, 99 dollars." Every value carries its own full context, every single time, regardless of where in the table a user starts reading. That is the entire difference between a table that merely displays information and a table that actually communicates it.
Where the Original Data Point Fits
The 19% figure cited above is the starkest part of this teardown: valid table markup is the exception on the web today, not the rule, and this table's before-state, styled <td> cells standing in for real headers, is exactly the pattern behind most of that 81%. None of the three fixes above are exotic. They are <th>, <caption>, and the headers/id attribute pairing, all native HTML, none requiring a framework change or a design change. The visual table on the page does not need to change at all. The markup underneath it does.
If your own pricing or comparison tables have never actually been listened to with a screen reader, that is worth doing before assuming they are fine, the same way this one looked fine until it was tested directly. For a broader run-through of every table pattern this applies to, our data table checklist covers the general case in more depth, and if you are curious how differently AI models handle this exact kind of fix when asked cold, we also ran four AI models through the same broken-table exercise and compared their output directly.
If you want a fast, free read on how your own tables and the rest of your site actually perform, our free accessibility tools will check real markup, not just visual appearance, and our team is reachable directly at experts@wcag.world if you want a second pair of eyes on a specific table before it ships. Following the W3C's own tables tutorial and the documented headers/id technique gets most tables the rest of the way there without much extra effort.
