Can a screen reader read your table?
Enter the page your pricing grid, fee schedule, timetable or spec comparison is on. We read every table in it and list them the way a screen reader lists them — then name the ones whose headers exist only in the styling, and the exact row to change.
Free, instant, no signup and no card. Nothing is stored.
Free, instant, no signup and no card. Point it at the page the table is actually on — a pricing grid, a fee schedule, a timetable, a spec comparison. We read the HTML your server sends for that one page. Nothing is stored.
The bold row that isn't a heading
Open the markup of almost any table on the web and the top row is bold. Sometimes it is bold because it is written with <th>, which is a heading cell and is styled bold by default. Just as often it is bold because each cell is a <td> with a <strong> inside it, or a class that sets font-weight: 700. On screen the two are indistinguishable. To a screen reader they are a table with headers and a table without.
The difference is what the reader hears in a cell. With headers: “Refunds, March, 412”. Without: “412”. A grid of sixteen columns and a hundred rows — an inflation table, a fee schedule, a benefits band — becomes a thousand numbers with no labels, and the only way through it is to memorise the column order. We ran this checker over public data pages while building it and found the defect on a federal statistics page and on a widely-cited CPI table: both open with a bold row of <td> cells, and both look perfect.
It survives because it is invisible to the people who would fix it. Nobody on the team sees a missing <th>. It is the same class of defect as a heading written as bold text or a form field labelled by the word next to it: the relationship is on screen and nowhere in the markup, which is exactly what SC 1.3.1 forbids.
What this checker refuses to call a failure
Tables are the category where a free checker is most likely to cry wolf, because for twenty years a <table> was how a page was laid out at all — and a layout table has no headers on purpose. So five things are called failures here, each under SC 1.3.1 at level A. Everything else is reported, argued, and kept out of the count.
role="presentation" is correct markup. It is how you tell assistive technology a table carries no row or column meaning. It is never a failure. It is listed in its own bucket, because it is also where a real data table can hide.
A missing caption is not a failure. WCAG does not require one. It is review, and the argument for adding it is navigation, not conformance.
A complex table without scope is not a failure. Browsers infer the usual associations from position and are usually right. Scope only becomes load-bearing when the inference goes wrong, which is a judgement about your data, not about your markup.
A nested table's cells are never counted as its parent's. The reader walks the document with a depth counter, so a layout table wrapped around a data table does not strip the inner one of its headers — and does not lend the outer one any.
Every rule above is pinned by fixtures that run on every deploy, including the negative ones: a legacy cellpadding grid, a table wrapping a form, a single-column list, a spacer row of empty cells, an image-only header with alt text, and a table with its closing tags left off.
What the checker looks for
Five failures, then the review and verify items — real problems that WCAG does not forbid outright, or that cannot be proved from source, reported separately and never counted as violations.
- Data table with no header cellsFailureWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- Every cell in this table is a <td>. Nothing tells assistive technology which row or column heads the others, so a screen reader announces the cell's contents and nothing else — "412", not "Refunds, March, 412". The bold top row a sighted reader uses is styling, and styling is not read out. This is the table equivalent of a form field with no label.
- The fix: Change the cells in the heading row (and the heading column, if there is one) from <td> to <th>. That single change is usually the whole fix; add scope="col" and scope="row" to be explicit.
- Heading row marked up as data cellsFailureWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- The first row is bold — or sits inside <thead> — so it is plainly meant as the heading row, and it is written with <td> instead of <th>. The relationship exists on screen and nowhere in the markup, which is exactly what SC 1.3.1 forbids: information conveyed by presentation alone.
- The fix: Swap those first-row <td> tags for <th scope="col">. The bold styling comes free with <th>, so the CSS that was doing it can go.
- Header cell that announces nothingFailureWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- A <th> with no text. Every data cell under it is announced with an empty header name, so the column is read out unlabelled while the table scores as having headers. The top-left corner cell of a grid is the usual home of this — but so is a column of checkboxes, icons or "edit" links whose header was left blank.
- The fix: Give the cell text, or a visually-hidden label if the column genuinely has no visible heading. If the cell is a spacer in the corner of a grid, that is the one case where an empty <td> is the right answer instead.
- headers="…" points at an id that is not in the tableFailureWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- A cell names its header cells by id, and at least one of those ids does not exist inside this table. The association silently fails: the cell is announced with no header at all, and because the markup looks deliberate it passes review. Renamed ids and copy-pasted rows are how this gets in.
- The fix: Match each headers value to the id on the header cell it means, or delete the attribute and let scope="col"/"row" do the work — for a table without merged headers, scope is both simpler and harder to break.
- scope value browsers ignoreFailureWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- scope only takes col, row, colgroup and rowgroup. Anything else — scope="column", scope="header", scope="1" — is dropped, and the header association the author thought they had written does not exist. It reads as done in a code review.
- The fix: Use scope="col" on a cell heading its column and scope="row" on a cell heading its row. colgroup and rowgroup are for a header spanning a band of them.
- Merged or two-way headers with no scope or headers attributeReviewWCAG 2.1 SC 1.3.1 Info and Relationships (Level A)
- This table has header cells spanning more than one row or column, or headers running both across the top and down the side, and no scope or headers attribute anywhere. Browsers guess the associations from position and are often right, which is why this is not counted as a failure — but on a table with a merged header band the guess is where it goes wrong, and a reader has no way to tell that it did.
- The fix: Add scope="col"/"row" to the simple headers and scope="colgroup"/"rowgroup" to the spanning ones. If the layout is irregular enough that scope cannot express it, give each header an id and point at it with headers on the data cells.
- Declared layout, but carries headers or a captionReviewWCAG 2.1 SC 1.3.1 (advisory)
- The table says role="presentation" (or role="none"), which strips every row, cell and header relationship out of the accessibility tree — and then supplies a <th> or a <caption>, which only exist to carry those relationships. One of the two is wrong. The role wins, so whatever the header cells were for is thrown away.
- The fix: If it is real data, remove the role. If it is layout, remove the <th> and <caption> so the markup says one thing.
- Data table with another table inside itReviewWCAG 2.1 SC 1.3.1 (advisory)
- A table inside a data table's cell. Screen readers announce entering and leaving each one, and the row and column position resets, so a reader who has arrowed into the inner table has to work out where they came back to. It is legal markup and occasionally the honest structure, but it is far more often a layout table that was never marked as one.
- The fix: If the outer table is only positioning things, give it role="presentation". If both are real data, consider two sibling tables with their own captions instead of one inside the other.
- Looks like a layout table, and does not say soReview
- No header cells, and something about the table says it is positioning things rather than presenting data — a single row or column, legacy cellpadding/cellspacing/border attributes, a form or a nav inside it, or another table. We do not count it as a failure, because a layout table with no headers is correct. But assistive technology cannot tell either, and announces "table, 3 columns" before reading whatever is inside.
- The fix: If it is layout, add role="presentation" and the announcement goes away. If it is data after all, it needs <th> cells — which is then the failure above.
- Table with no caption or accessible nameReviewWCAG 2.1 SC 1.3.1 (advisory)
- Not a failure, and worth a minute anyway: screen readers list a page's tables the way they list its headings and links, and a table with no name is listed as "table with 4 columns and 12 rows". On a page with one table the surrounding text usually carries it. On a page with several — a fee schedule, a comparison, a timetable — the list is the navigation, and an unnamed table has to be entered to be identified.
- The fix: Add a <caption> as the first child of the table. It is visible by default, which is usually an improvement, and can be styled or visually hidden if the heading above it already says the same thing. aria-label works too.
- Declared layout (role="presentation")Verify
- This is correct markup and not a violation — it is how you tell assistive technology that a table is positioning content and carries no row/column meaning. It is listed because it is also where a real failure hides: a pricing grid, a fee schedule or a spec comparison marked presentational is information the page silently withholds from anyone who cannot see the grid.
- The fix: Skim the list. Anything a sighted reader reads by row and column is data, and needs the role removed and header cells added.
The whole fix, in markup
For the overwhelming majority of tables the fix is one row of one template. Before:
<table class="prices">
<tr>
<td><strong>Plan</strong></td>
<td><strong>Monthly</strong></td>
</tr>
<tr><td>Starter</td><td>$29</td></tr>
</table>After:
<table class="prices">
<caption>Plans and monthly prices</caption>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col">Monthly</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Starter</th><td>$29</td></tr>
</tbody>
</table>The <strong> tags come out because <th> is already bold. Nothing about the page changes visually, and a screen reader now announces “Starter, Monthly, $29” instead of “$29”.
Common questions
- What does a table accessibility checker check?
- Whether a screen reader can read your table two-dimensionally, the way your eyes do. It reads every <table> the page serves and answers four questions about each: are there header cells at all, does each header announce something, do the scope and headers= attributes resolve, and is a table that carries no data relationships declared as layout. Each failure is named under WCAG 2.1 SC 1.3.1 Info and Relationships, level A.
- Why does a table need <th> when the top row is obviously the heading?
- Because "obviously" is a visual judgement and a screen reader is not making it. A sighted reader lands on the cell that says 412 and picks up "March" from the top of the column and "Refunds" from the start of the row without deciding to. A screen reader can do exactly the same thing — announce "Refunds, March, 412" — but only if the markup says which cells are headers. Given a grid of nothing but <td>, it announces "412". Bold styling is not read out.
- Is a table with no <th> always a WCAG failure?
- No, and this is where most checkers cry wolf. For twenty years a <table> was how a page was laid out, and a layout table has no headers on purpose — giving it some would be the bug. So this checker only calls it a failure when nothing about the table suggests layout: it needs at least two rows and two columns, no legacy cellpadding/cellspacing/border="0"/width attributes, no <form>, <nav> or <iframe> inside it, and no nested table. Anything that trips one of those is reported as "looks like a layout table" and kept out of the failure count.
- Do I need a <caption> on every table?
- No. WCAG does not require one — SC 1.3.1 is satisfied by the header cells — so a missing caption is reported for review here and never counted as a failure. It is still worth adding. Screen readers list a page's tables the way they list its headings and links, and a table with no name appears in that list as "table with 4 columns and 12 rows". On a page with one table the surrounding text usually carries it; on a fee schedule with six, the list is the navigation.
- What is scope and when do I actually need it?
- scope="col" on a cell says it heads its column; scope="row" says it heads its row. For a plain grid with one header row you do not strictly need it — browsers infer the association from position and get it right. It becomes load-bearing when the table is irregular: headers running both across the top and down the side, or a header cell spanning a band of columns. This checker reports a complex table with no scope for review, not as a failure, because the inference is usually correct. It does fail an invalid value — scope="column" and scope="header" are silently dropped by every browser, and the association the author thought they had written does not exist.
- What about headers="…" and ids?
- That is the escape hatch for tables too irregular for scope: each header cell gets an id, and each data cell names the ids that head it. It is precise and it is fragile — rename an id, copy a row between tables, and the reference points at nothing. The association then fails silently while the markup still looks deliberate. This checker resolves every headers value against the ids inside the same table and fails the ones that do not resolve.
- My table is built by JavaScript. Will this see it?
- No. This reads the HTML your server sends, before any script runs, which is deliberate: it is fast, it is free, and it is the same markup a crawler sees. If the checker says there are no tables on a page where you can see one, that itself is the answer — the table arrives later. The free WCAG 2.1 AA scan renders each page in a real browser and will see it.
- Is a grid of divs with role="table" the same thing?
- It can be, if every part is there: role="table" on the container, role="row" on each row, and role="columnheader" / role="rowheader" / role="cell" on each cell. Miss one and the whole structure collapses into unrelated text. This checker reads <table> elements and counts role="columnheader" and role="rowheader" on cells inside them; a pure div grid is outside what it can verify, and is a good reason to run the scan, which reads the rendered accessibility tree.
- Does this store my domain?
- No. The check is an HTTP GET of the page you name, run inside the request and discarded when it answers. There is no account, no email field and no row written anywhere. If you go on to run the free scan, that one does keep a report so you can come back to it, which is the whole point of a report.
Keep reading
- What does your heading outline look like? →
- Which form fields have no label? →
- Can a phone zoom your website? →
- Can a keyboard reach everything a mouse can? →
- Is your ARIA being thrown away? →
- What do your links say out of context? →
- What is your hamburger button called? →
- Which images have no alt text? →
- Which voice will read your page aloud? →
- The colour contrast checker →
- The WCAG compliance tool →
- The ADA compliance checker →