ESC

Type to search the knowledge base.

Accessible Tables

Data tables vs layout tables — th scope, captions, headers, responsive strategies, and when to use grids instead.

intermediate3 min read
  • accessibility
  • accessible-tables

Screen reader users navigate data tables by cell, hearing row/column headers. If you fake a table with divs or use tables for layout, that navigation breaks. Use <table> for tabular data; use CSS layout for page structure.

Docs: WAI Tables, MDN table.

Minimal data table

<table>
  <caption>Team members — Q3</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
      <th scope="col">Location</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Ada Lovelace</th>
      <td>Engineer</td>
      <td>London</td>
    </tr>
    <tr>
      <th scope="row">Alan Turing</th>
      <td>Researcher</td>
      <td>Manchester</td>
    </tr>
  </tbody>
</table>
  • <caption> — table name (or aria-labelledby)
  • scope="col" / scope="row" — associates headers
  • Complex tables may need headers + id

Layout tables: don’t

<!-- Bad page layout -->
<table>
  <tr>
    <td>nav</td>
    <td>content</td>
  </tr>
</table>

Use flex/grid. If you must support legacy, role="presentation" on layout tables removes table semantics — still prefer real layout CSS.

Sortable columns

<th scope="col">
  <button type="button" aria-label="Sort by Name, currently ascending">
    Name
    <span aria-hidden="true">▲</span>
  </button>
</th>

Announce sort changes via live region or button name updates.

Responsive strategies

Approach Notes
Horizontal scroll Keep table; ensure keyboard can scroll container
Stacked rows (CSS) Preserve header association carefully — hard
Card list at small breakpoints Different markup path; not the same table
Priority columns Hide less critical cols with care

Never strip header associations when restyling.

Interactive cells

Controls inside cells need their own names:

<td>
  <button type="button" aria-label="Delete Ada Lovelace">Delete</button>
</td>

Footguns

  1. Div grids announced as plain text without headers.
  2. Empty header cells without labels.
  3. Merged cells without correct headers.
  4. Tables missing captions when multiple tables exist.

Interview out-loud answer

“Data tables use table/th/td with scope or headers so AT can announce relationships. Captions name the table. I don’t use tables for layout. Sortable headers are real buttons with clear labels.”

Complex headers

When cells need multiple headers (multi-level column groups), use id + headers. Prefer simpler table shapes when product allows — complex associations are easy to break in refactors.

CSV export as an a11y affordance

For dense analytics tables, offer Download CSV next to the grid. It’s not a replacement for an accessible table, but it gives AT and power users a robust alternative for analysis.

Virtualized tables

Windowing libraries recycle DOM rows. Keep a real header row, don’t lose focus on recycle, and keep any exposed row counts honest. Test keyboard scroll plus SR cell reading on the chosen library.

Sorting and live updates

When sort changes, update the active header’s accessible name (“Name, sorted ascending”) and consider a polite status: “Sorted by Name ascending.” Don’t rely on visual carets alone.

Empty and loading tables

Announce empty states in the caption or a status region: “No invoices match these filters.” For loading, either keep headers visible with a status “Loading rows” or swap to a labeled progress region — don’t leave a silent blank grid.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Further reading

Related guides