Tables a Screen Reader Can Actually Navigate

A well-marked-up table lets a screen reader user move through data like a spreadsheet — every cell announced with its headers. A div-built one reads as a stream of loose numbers.

NamePlanSeatsFDMFoundryPro12Ember LabsFree3Trail CoStarter5row 3 · Plan · Free
Steve
Accessibility (A11Y) Specialist
May 19, 2026
6 min read

The first time I watched a screen reader user work through a data table, I was struck by how much it resembled someone driving a spreadsheet. She moved cell by cell with keyboard commands, and at each stop the screen reader stitched together the coordinates for her: "Revenue, EMEA, $1.2 million." Column header, row header, value. She could wander anywhere in a fifty-row table and always know exactly what she was looking at.

Then she opened the second table in our app — one a teammate had built out of divs and CSS grid, because styling a real table had felt like a chore — and the same commands did nothing. The screen reader saw no table at all. Just a long stream of loose text: "EMEA. 1.2 million. 8 percent. APAC. 940 thousand..." Forty values with no coordinates. She'd have to memorize the column order from the top of the page and count on her fingers.

Same data. Same pixels, near enough. Completely different machine underneath. Tables are one of the clearest examples I know of markup being the feature.

Use a table, and mean it

Everything good about table accessibility flows from one decision: tabular data lives in a real <table>. Screen readers ship a whole navigation mode for tables — move by row, move by column, announce headers with every cell, jump to a table by its name — and every bit of it keys off the semantics of <table>, <tr>, <th>, and <td>. A grid of divs, however faithfully it mimics the look, opts out of all of it.

Two cautions live under this heading. First: AI assistants disproportionately generate div-based "tables," because flex and grid layouts are the path of least resistance for styling. If a generated diff renders rows and columns of data without a <table> element, that's worth a review comment every time. Second, subtler: even a real <table> can lose its semantics. Setting display: flex or display: grid on table elements — usually in pursuit of a responsive layout — causes some browsers to drop the table from the accessibility tree entirely. The markup says table; the rendering engine shrugs. If you restyle table internals, check the accessibility tree in devtools afterward to confirm it still says table.

(The old inverse sin — using <table> for page layout — has mostly died out, thankfully. Semantics should describe what the content is, in both directions.)

Caption, headers, scope

A minimal accessible table needs three things beyond the element itself: a name, real header cells, and explicit scope.

<table>
  <caption>Q1 revenue by region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Revenue</th>
      <th scope="col">Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">EMEA</th>
      <td>$1.2M</td>
      <td>+8%</td>
    </tr>
    <tr>
      <th scope="row">APAC</th>
      <td>$940K</td>
      <td>+12%</td>
    </tr>
  </tbody>
</table>

<caption> is the table's name, and it's announced when a user reaches or jumps to the table. Screen reader users often navigate by pulling up a list of the tables on a page — a caption is what makes yours findable in that list, versus "table, 3 columns, 51 rows." If the design already shows a heading above the table, you can visually hide the caption, but give the table a name one way or another.

<th> versus <td> is the difference between a header and a value. Those beautiful cell-by-cell announcements — "Revenue, EMEA, $1.2 million" — only happen because the screen reader knows which cells are headers. A first row of bolded <td>s looks identical and provides nothing.

scope removes ambiguity about which direction a header applies: scope="col" for column headers, scope="row" for row headers. In a simple grid, screen readers can often guess — but "often guess" is a poor foundation, and the attribute costs you nothing. Row headers are the most commonly skipped piece, and they're what lets a user land in the middle of the table and still hear which row they're in.

When headers get complicated

Multi-level headers — merged cells spanning column groups, two tiers of headings — can technically be wired up with id attributes on headers and headers attributes on every data cell listing its governing headers. It works, and when a genuinely complex table is unavoidable, it's the tool.

But I'll offer the advice I give in review: a table complex enough to need headers/id plumbing is usually a table struggling to be two simpler tables. Splitting it — or restructuring so each row makes sense with single-level headers — is nearly always the kinder outcome, for the maintainer and for every reader. Cognitive accessibility counts too, and nobody's ever complained a table was too easy to understand.

Sortable columns that say so

Interactive tables raise the bar. A sortable column has two jobs: the header must be operable (a keyboard user can activate the sort) and the state must be perceivable (a screen reader user can tell how the table is currently sorted). The pattern:

<th scope="col" aria-sort="ascending">
  <button>
    Revenue
    <svg aria-hidden="true"><!-- ▲ --></svg>
  </button>
</th>

The details that matter:

  • The click handler goes on a real <button> inside the <th> — not on the <th> itself. That single choice buys focusability, Enter and Space activation, and a proper announcement ("Revenue, button"), for free. A th onclick is the fake-button problem in a party hat.
  • aria-sort lives on the <th>, with a value of ascending or descending — and only on the currently sorted column. Screen readers announce it with the header: "Revenue, sort ascending, button." Setting it on every sortable column, or leaving stale values behind after a re-sort, tells users a confident lie.
  • The arrow icon gets aria-hidden="true". It's decoration; the state is already carried by aria-sort. Otherwise some screen readers try to pronounce the icon or its codepoint, which is exactly as pleasant as it sounds.
  • When the user re-sorts, the visible data changes but nothing announces it. Updating aria-sort covers the state; if you want the polish, a polite live region saying "Sorted by revenue, ascending" closes the loop.

A table checklist for review

When a data table crosses your desk — hand-written or generated — this is the whole pass:

  • Tabular data is in a <table>, not a grid of divs; and restyled tables still appear as tables in the accessibility tree.
  • There's a <caption> (visible or visually hidden) naming the table.
  • Header cells are <th> with scope="col" or scope="row"; the first column has row headers where rows have identities.
  • If the table needs headers/id gymnastics, ask whether it should be two tables.
  • Sortable columns: real <button> in the header, aria-sort on the active <th> only, decorative arrows hidden.

Five checks, maybe two minutes. The payoff is the difference I saw in that demo: data someone can navigate versus data someone has to endure. The person driving the spreadsheet-like table wasn't working around our app — the markup was working for her. That's the whole trick, and it's been sitting in HTML since before most of our frameworks existed.

tablesscreen-readerssemantic-htmldata