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.

Where a table's name and description come from Four layers in order of precedence for the accessible name, from aria-labelledby down to the caption element, with aria-describedby supplying the separate description. Where a table's name and description come fromaria-labelledbyWins over everything. Points at one or more visible elements by id; the table namebecomes their text.aria-labelWins over caption. Invisible to sighted users, so the spoken and visible names candrift apart.captionThe default name source for a table. Visible, first child, one per table.aria-describedbyNot a name. Supplies the description that is read after the name and dimensions.
The caption is the lowest-precedence name source — which is exactly why an unexpected 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.

Caption versus summary Side-by-side comparison of what belongs in a table caption and what belongs in a separate summary paragraph. Caption versus summary✓ Caption — the nameAnnounced on every entry and in the table listReflects the current filter when the data setchangesOne line, visible by default✓ Summary — the reading guideRead once, after the name and dimensionsReferenced with aria-describedby from thetableOnly for tables whose layout is not obvious
The caption answers "which table is this"; the summary answers "how do I read it".

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.

How each reader surfaces the name and the description Matrix of NVDA, JAWS, VoiceOver and TalkBack showing whether each reads the caption on entry, lists it in the table list, and reads aria-describedby. How each reader surfaces the name and the descriptionScreen readerCaption on entryTable listaria-describedbyNVDA + FirefoxRead, after "table"Listed by captionRead after dimensionsJAWS + ChromeRead before dimensionsListed by captionVerbosity dependentVoiceOver + SafariRead on entryRotor itemAfter a pauseTalkBack + ChromeRead on focusNo table listOften skipped
The name is dependable everywhere; the description is best-effort — never put essential information only in the summary.

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.

Permalink to "Related"

← Back to Semantic HTML Table Construction