# Tables with a title but no caption

Source: https://easeweb.dev/learn/table-captions
Topics: Tables and data > Tables have a caption (WCAG 1.3.1)
The fix: Make the title shown with each table its name. Either move the title into a caption element, the table's first child, styled to look as it did; or keep the title where it is, give it an id and point the table's aria-labelledby at it. Never draw the title as a row of the table's cells.
Test with: screen reader, keyboard, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Techniques/html/H39 https://www.w3.org/WAI/tutorials/tables/caption-summary/ https://www.w3.org/TR/html-aam-1.0/#table-element-accessible-name-computation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/caption

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's delivery page shows two tables, one under the bold title "Standard delivery" and one under "Express delivery". Both have the same columns, Weight and Price, and differ only in their prices. A sighted reader glances at the title and knows which table they are in. A screen reader user who presses T to jump to the next table, as NVDA and JAWS allow, hears something like "Table, 2 columns, 3 rows" for the first one, and the same again for the second. To learn whether "$9" is the standard or the express price, they have to leave the table and read back up the page to the line above it.

## Why it fails

The title is shown as belonging to its table, but in the code it is a paragraph that happens to come first. Nothing ties the two together, so the table has no accessible name. Some sites put the title inside the table instead, as a first row with one cell spanning every column. That is no better: the table still has no name, and the title is read as a cell of the data, counted as one more row. WCAG 1.3.1 Info and Relationships requires that a relationship shown on screen is also in the code. For a table's title, the HTML way is the `caption` element, which WCAG's technique H39 names for exactly this; `aria-labelledby` makes the same tie with ARIA.

A real heading just above a table is a weaker case: it is in the page's heading structure, and someone reading down the page hears it right before the table. Jumping by table still skips it, though, so tie it to the table too.

## Who is affected

The longer the page, the more it matters. A delivery page with two tables costs a little backtracking; a results page, a timetable split by day or a pricing page with a table per region leaves the person reading into every table to find the one they came for, and reading the wrong table's figures if they guess.

## What automation and AI miss

A paragraph above a table passes every scanner, because nothing says the paragraph is a title. axe has a rule, `table-fake-caption`, for a first row made of one cell spanning the table, but it is experimental, so it is not part of a standard run, and this audit reports it as a lead for a person to confirm. For the paragraph, this audit raises a lead too: a data table with no name gets one, quoting the short line just above it as the likely title. Whether the line above a table is its title, and whether each name tells the tables apart, needs someone to look. AI code generators often write the title as a heading or a styled `div` above the table, because that is how the design shows it, and leave out the `caption`.

## Before: the title in a paragraph above the table

```html
<p class="table-title">Express delivery</p>
<table class="rates">
  <thead>
    <tr>
      <th scope="col">Weight</th>
      <th scope="col">Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Up to 1 kg</th>
      <td>$9</td>
    </tr>
    <tr>
      <th scope="row">1 to 5 kg</th>
      <td>$15</td>
    </tr>
  </tbody>
</table>
```

The headers are right, but the title is a separate paragraph, so the table has no name. Jumping to it, a screen reader says "Table, 2 columns, 3 rows" and nothing that tells it from the standard delivery table above it.

## Before: the title as the table's first row

```html
<table class="rates">
  <thead>
    <tr>
      <td colspan="2" class="table-title">Express delivery</td>
    </tr>
    <tr>
      <th scope="col">Weight</th>
      <th scope="col">Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Up to 1 kg</th>
      <td>$9</td>
    </tr>
    <tr>
      <th scope="row">1 to 5 kg</th>
      <td>$15</td>
    </tr>
  </tbody>
</table>
```

The title has moved into the table, but as a cell of data. The table still has no name, so a screen reader announces "Table, 2 columns, 4 rows", counting the title as a row, and reads "Express delivery" only when it reaches that first cell, as data.

## After: a caption

```html
<table class="rates">
  <caption>Express delivery</caption>
  <thead>
    <tr>
      <th scope="col">Weight</th>
      <th scope="col">Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Up to 1 kg</th>
      <td>$9</td>
    </tr>
    <tr>
      <th scope="row">1 to 5 kg</th>
      <td>$15</td>
    </tr>
  </tbody>
</table>
```

```css
.rates caption {
  text-align: start;
  font-weight: bold;
  padding-block-end: 0.5rem;
}
```

The title is now the table's `caption`, its first child, and the caption is the table's name. A screen reader announces "Express delivery, table, 2 columns, 3 rows" as it arrives, and lists the table by that name. Browsers centre a caption by default, so one rule sets it back to the start and keeps the bold title as it was drawn; the page looks the same as before. The paragraph, or the title row, is gone.

## After: aria-labelledby pointing at the title

```html
<p id="express-title" class="table-title">Express delivery</p>
<table class="rates" aria-labelledby="express-title">
  <thead>
    <tr>
      <th scope="col">Weight</th>
      <th scope="col">Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Up to 1 kg</th>
      <td>$9</td>
    </tr>
    <tr>
      <th scope="row">1 to 5 kg</th>
      <td>$15</td>
    </tr>
  </tbody>
</table>
```

The title stays where it is and gets an `id`, and the table names itself after it. A screen reader announces the same "Express delivery, table, 2 columns, 3 rows". Use it when the title can't move inside the table: a card header that also holds buttons or a filter, or a heading the page outline needs. Each `id` must be unique on the page and still point at the title after the component is reused, and a title row inside the table has to move out above it first.

## Which option to use

Both give the table its visible title as its name and pass 1.3.1. Use a `caption`: it does the same job with plain HTML, needs no `id` to keep unique and in step, and keeps the title inside the table, so it moves, hides and prints with it. Keep `aria-labelledby` for a title that has to stay outside the table, such as a card header with controls in it.

## Implementation decisions

Some cases need a choice beyond the fix itself:

- One caption, first: a `caption` must be the table's first child, and a table has only one. A second line, such as "Prices include tax", can go in the same caption on a line of its own, or in a paragraph tied to the table with `aria-describedby`.
- A heading that is also the title: a caption may hold a heading, `<caption><h3>Express delivery</h3></caption>`, so the title is in the heading outline and names the table. Check how your screen readers read it before you rely on it.
- Tables in a `figure`: when a table is the only content of a `figure`, HTML says to use the `figcaption` instead of a `caption`. The `figcaption` does not name the table, so point the table's `aria-labelledby` at it as well, or table navigation won't say it.
- No title on screen: 1.3.1 doesn't ask for a name the design doesn't show, but a page with several tables is easier to use with one. Add a visible caption if the design allows, and otherwise an `aria-label`.
- The old `summary` attribute is obsolete. Put a description in the caption or link one with `aria-describedby`.
- Layout tables: a table used only to place content gets no caption. Give it `role="presentation"`, or better, replace it with CSS layout.

The headers inside each table are a separate failure, covered by [data tables with headers that are only bold text](/learn/table-headers). The two belong in the same table component.

## Verify the fix

1. In the code, find every data table with a title shown above it or in it, and confirm the title is its `caption` or is referenced by its `aria-labelledby`.
2. Confirm no table's first row is a single cell spanning every column, and each caption is the table's only one and its first child.
3. With a screen reader, jump from table to table (T in NVDA and JAWS) and confirm each table is announced with its title.
4. Open the screen reader's list of tables, such as the VoiceOver rotor's, and confirm each name tells the table apart from the others.
5. Compare the page with how it looked before: the title is in the same place and style, at 200% zoom and on a narrow screen.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the table component changes.

## Limits

This is a starting pattern, not a guarantee for every table. Screen readers differ in when they read a caption and how they word a table's size, and not all of them list tables. A name only helps when it says what is in the table: two tables captioned "Prices" are as hard to tell apart as two with no caption.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
