# Data tables with headers that are only bold text

Source: https://easeweb.dev/learn/table-headers
Topics: Tables and data > Data tables have headers (WCAG 1.3.1)
The fix: Mark each header cell as a header. Either use th with scope="col" or scope="row", or, where a header covers only part of a row or column, give each td a headers attribute listing the ids of its th cells. Where the markup cannot be a table, use the full set of ARIA table roles.
Test with: screen reader, keyboard, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/tutorials/tables/ https://www.w3.org/WAI/tutorials/tables/multi-level/ https://www.w3.org/WAI/ARIA/apg/patterns/table/ https://developer.mozilla.org/en-US/docs/Web/HTML/Element/th

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 pricing page compares plans in a table. The top row reads Plan, Monthly and Yearly in bold, and each row below starts with a plan name. A sighted reader glances up and left to see what a figure means. A screen reader user moving through the cells hears "$12", "$120", "$30", "$300", and nothing else. To learn that "$30" is the Team plan's monthly price, they have to move back to the top of the column and to the start of the row, and keep both in mind.

## Why it fails

Every cell is a `td`. The header row is bold because of a class, and the plan names are bold because of a style on the first column. Bold is a visual relationship; it is not in the markup. WCAG 1.3.1 Info and Relationships requires that structure shown visually is also available in code, and in a data table the main structure is which header applies to which cell. With no `th` and no ARIA header roles, the browser has nothing to pass on, so screen readers cannot announce headers with each value.

## Who is affected

The table is small here, and counting back is only tiring. In a timetable, a results table or a comparison with a dozen columns, the person has to rebuild the grid from memory, and a single slip gives them the wrong price or the wrong time.

## What automation and AI miss

A scanner can say that a table has no `th`, but not which cells ought to be headers, and some tools stay quiet when a table has a `th` that heads the wrong thing. It cannot tell whether `scope` points the right way, or whether a header row that sorting rebuilt still matches the data. AI code generators often produce a grid of `div` elements styled as a table, or add `role="table"` without the row and cell roles under it. Only reading the table with a screen reader, cell by cell, confirms that each value is announced with the headers a sighted reader would use.

## Before: header cells drawn with td and bold text

```html
<table class="prices">
  <tr class="head">
    <td>Plan</td>
    <td>Monthly</td>
    <td>Yearly</td>
  </tr>
  <tr>
    <td class="plan">Starter</td>
    <td>$12</td>
    <td>$120</td>
  </tr>
  <tr>
    <td class="plan">Team</td>
    <td>$30</td>
    <td>$300</td>
  </tr>
</table>
```

The `head` and `plan` classes only make the text bold. Every cell is data, so a screen reader announces "$30" alone.

## After: th with scope

```html
<table class="prices">
  <thead>
    <tr>
      <th scope="col">Plan</th>
      <th scope="col">Monthly</th>
      <th scope="col">Yearly</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Starter</th>
      <td>$12</td>
      <td>$120</td>
    </tr>
    <tr>
      <th scope="row">Team</th>
      <td>$30</td>
      <td>$300</td>
    </tr>
  </tbody>
</table>
```

Each header is a `th`, and `scope` says whether it heads its column or its row. A screen reader now announces "Team, Monthly, $30" on that cell, and when the person moves down a column it repeats only the row header that changed. `th` is bold by default, so the old classes can go.

## After: headers and id, for a table scope cannot describe

```html
<table class="prices">
  <tr>
    <td></td>
    <th id="monthly">Monthly</th>
    <th id="yearly">Yearly</th>
  </tr>
  <tr>
    <th id="starter">Starter</th>
    <td headers="starter monthly">$12</td>
    <td headers="starter yearly">$120</td>
  </tr>
  <tr>
    <th id="team">Team</th>
    <td headers="team monthly">$30</td>
    <td headers="team yearly">$300</td>
  </tr>
</table>
```

Each `th` has an id, and each `td` lists the ids of every header that applies to it. The screen reader announces the same "Team, Monthly, $30". On this table the method costs more markup than `scope` for no gain. It earns its place in irregular tables, where a header covers only some of the cells in its row or column, such as a sub-heading that splits a column in two. Every id must belong to a `th` in the same table, and the table must not use `scope` as well.

## After: ARIA table roles, when the markup cannot be a table

```html
<div role="table" aria-label="Plan prices" class="prices">
  <div role="row">
    <span role="columnheader">Plan</span>
    <span role="columnheader">Monthly</span>
    <span role="columnheader">Yearly</span>
  </div>
  <div role="row">
    <span role="rowheader">Starter</span>
    <span role="cell">$12</span>
    <span role="cell">$120</span>
  </div>
  <div role="row">
    <span role="rowheader">Team</span>
    <span role="cell">$30</span>
    <span role="cell">$300</span>
  </div>
</div>
```

Some components render a grid of `div` elements and cannot emit table markup. The roles give the same structure: a table, rows, column and row headers, and cells. Every level has to be present, because a `cell` outside a `row`, or a `row` outside a `table`, breaks the grid. Unlike `th`, the roles bring no default styling.

## Which option to use

All three pass 1.3.1 on this table. Use `th` with `scope`: it does the same job as the other two with the least code, needs no ids to keep in step, and works in every screen reader and browser reading mode. Reach for `headers` and `id` only when the table is irregular enough that `scope` cannot say which headers apply, and for the ARIA roles only when the markup cannot be a `table`. In that case, check every role is in place, since a missing one silently breaks the grid.

## Implementation decisions

Several things change how a header row behaves after the markup is right:

- `th` without `scope`: browsers infer the direction in a simple table with one header row, but adding `scope` costs nothing and holds when someone later adds a row header.
- Responsive tables: CSS that sets `display: block` or `grid` on table elements can remove their table semantics in some browsers. Test the narrow layout with a screen reader, or keep the table and let it scroll inside a named, focusable region.
- Sorting and filtering: a sort that rebuilds the body must keep the row `th`. Say which column is sorted with `aria-sort`, which is a separate topic.
- Layout tables: a table used only to place content should not have `th` or `caption`. Give it `role="presentation"`, or better, replace it with CSS layout.

A `caption` names the table so that people moving between tables can tell them apart. It is covered by its own guide, but it belongs in the same component.

## Verify the fix

1. In the code, find every cell that heads a row or column, and confirm each is a `th` or carries a header role.
2. With a screen reader, move cell by cell using its table commands and confirm each value is announced with the row and column headers a sighted reader would use.
3. Move down a column and across a row, and confirm only the header that changes is repeated.
4. Check that each `th` has `scope="col"` or `scope="row"`, or that every `headers` id points at a `th` in the same table, and that no table mixes the two.
5. Sort, filter and narrow the window, then repeat the screen reader check.

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 how much header text they repeat and when. Tables with headers several levels deep can be hard to follow even with perfect markup; where the data allows, split them into simpler tables.
