Writing Table Captions and Summaries That Screen Readers Use
Permalink to "Writing Table Captions and Summaries That Screen Readers Use"The <caption> element gives a data table its accessible name, and that name is what a screen reader user hears when they jump between tables, open the table list, or land on the table with a quick-navigation key. A table without one is announced as “table, 12 rows, 5 columns” — accurate, and useless on a report page that carries four tables with the same shape.
This page covers the element itself, the separate question of when a table needs a summary, and the patterns that look like captions but are not. It sits under semantic HTML table construction and assumes the header cells are already wired as described in scope and headers in complex tables.
Spec reference
Permalink to "Spec reference"The HTML Living Standard defines <caption> as “the title of the table that is its parent”. It must be the first child of <table>, and there can be only one. The HTML-AAM mapping computes the table’s accessible name from the caption when no aria-label or aria-labelledby is present, which is why the caption is the cheapest reliable name available: it needs no id plumbing and it survives a framework re-render.
Two success criteria are in play. SC 1.3.1 Info and Relationships (Level A) covers the programmatic association — the name has to be attached to the table, not merely near it. SC 2.4.6 Headings and Labels (Level AA) covers quality: a caption of “Table 1” satisfies the first criterion and fails the second, because it does not describe the content.
The old summary attribute on <table> is obsolete in HTML. Some screen readers still read it, most do not, and no browser exposes it consistently. Do not use it for new work, and remove it when you touch a legacy table — its content belongs in a visible description.
aria-label silently replaces it.When to use a caption, and when a summary as well
Permalink to "When to use a caption, and when a summary as well"Every data table gets a caption. The only judgement call is whether the caption is visible. Keep it visible by default: sighted users benefit from a label directly attached to the grid, and a visible caption cannot drift out of sync with a heading somebody edits later. Hide it with a visually-hidden utility only when a heading immediately above the table already says the same thing, and even then consider aria-labelledby pointing at that heading instead, so there is one source of truth.
A summary is a different thing. It explains the structure — “Rows are regions; the last two columns are quarter-on-quarter change” — rather than naming the data. Most simple tables do not need one. Complex ones do: tables with two levels of column headers, row groups, a totals row that is not at the bottom, or cells whose meaning depends on colour.
The misapplication to name explicitly is stuffing the summary into the caption. A caption that runs to three sentences is read in full every time the user moves into the table, and again on every table-list lookup. Keep the caption short; put the structural explanation in a paragraph and reference it with aria-describedby.
Annotated code example
Permalink to "Annotated code example"<!-- SC 1.3.1: the summary paragraph sits before the table in reading order -->
<p id="inv-summary" class="table-summary">
Rows are grouped by customer. Each group ends with a subtotal row;
the final row is the grand total. Amounts are in euros.
</p>
<table aria-describedby="inv-summary"> <!-- description, not name -->
<!-- SC 1.3.1 + 2.4.6: first child, descriptive noun phrase -->
<caption>
Open invoices, March 2026
<!-- the live part: updated when filters change the data set -->
<span class="caption-filter">— filtered to overdue</span>
</caption>
<thead>
<tr>
<th scope="col">Invoice</th> <!-- SC 1.3.1: column header -->
<th scope="col">Customer</th>
<th scope="col">Due</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-1042</th> <!-- SC 1.3.1: row header -->
<td>Northwind</td>
<td>2026-03-04</td>
<td>1,280.00</td>
</tr>
</tbody>
</table>
The filter suffix inside the caption is deliberate. When a user filters a report down to overdue invoices, the table is now a different data set, and its name should say so. Updating the caption text is enough — no live region is needed for the name itself, because the next time the user enters the table the new name is read. The count change is a separate announcement, handled as in announcing filter result counts.
In a component library, expose the caption as a required prop rather than an optional slot. React table wrappers that make it optional end up with a codebase full of unnamed tables, because nothing breaks visually when it is omitted.
// A table wrapper that refuses to render without a name
function DataTable({ caption, summary, columns, rows }) {
if (!caption) throw new Error('DataTable: caption is required (SC 1.3.1)');
const summaryId = React.useId();
return (
<>
{summary && <p id={summaryId} className="table-summary">{summary}</p>}
<table aria-describedby={summary ? summaryId : undefined}>
<caption>{caption}</caption>
{/* thead / tbody rendered from columns and rows */}
</table>
</>
);
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT-specific deviations |
|---|---|---|
T (NVDA/JAWS browse mode) to the table |
“Open invoices, March 2026, table with 4 columns and 12 rows” | JAWS reads the caption before the dimensions; NVDA after the word “table” |
Table list (NVDA Insert+F7, JAWS Insert+Ctrl+T) |
The caption text is the list entry | An uncaptioned table appears as “table” or by its first cell |
| VoiceOver rotor, Tables | Caption as the rotor item | Safari exposes aria-describedby only after a pause, not in the rotor |
| Arrow into first cell | Name is not repeated; the header and cell are | Some JAWS verbosity levels repeat the caption on each table entry |
| Filter changes caption | No immediate announcement | Heard on the next table entry; pair with a count announcement |
The description is quieter than the name. NVDA reads aria-describedby after the dimensions on entry; VoiceOver often reads it only after a short delay, and users who move quickly will skip it. That is fine — the summary is a reading guide for someone who needs it, not a message that must be heard.
Integration context
Permalink to "Integration context"A caption is the first thing to go missing when a <div> grid is converted to a table, because the div version usually had a heading somewhere above it that nobody thinks to connect. The conversion checklist in converting div-based grids to semantic tables should include “move or reference the heading” as an explicit step.
For interactive grids with role="grid", the same rule applies but the mechanism changes: there is no <caption> on a div[role=grid], so the name comes from aria-labelledby pointing at a visible heading. The broader rules for choosing between those mechanisms are in accessible names and descriptions for data widgets.
Gotchas
Permalink to "Gotchas"A stray aria-label overrides the caption. Design-system table components often set aria-label="Data table" as a default. That silently replaces a good caption with a generic name. Search your component library for default labels and remove them.
A caption that is not the first child. Some templating layers render a <colgroup> or a comment node before the caption. Comments are harmless; any element before <caption> makes the document invalid and some browsers stop mapping the caption as the name.
Captions inside sticky or scrolling wrappers. If the table sits in a horizontally scrolling container, the caption scrolls away with the columns. Style it with caption-side: top and keep it outside the scroll region visually by giving the wrapper its own heading referenced with aria-labelledby, or accept that the caption belongs to the table’s own scroll.
FAQ
Permalink to "FAQ"Is the summary attribute on a table still valid?
No. It is obsolete in the HTML Living Standard, and screen reader support is inconsistent — some read it, most ignore it. Move its text into a visible paragraph next to the table and reference that paragraph with aria-describedby.
Can I hide the caption visually?
Yes, with a visually-hidden class that keeps it in the accessibility tree, but only when a visible heading immediately above the table already says the same thing. In that case aria-labelledby pointing at the heading is usually the better choice because it leaves one source of truth.
Should the caption change when the user filters the table?
If the filter changes what data set the table represents, yes — append the filter to the caption so the name stays accurate on the next entry. Announce the new row count separately through a status region, because a caption change on its own is not announced.
Related
Permalink to "Related"- scope and headers in complex tables — the header wiring the summary describes
- Converting div grids to semantic tables — where the caption usually goes missing
- Accessible names & descriptions for data widgets — the naming rules behind caption