Home HTML

HTML <table> for data, with accessible headers

<table> is the right element for tabular data and only tabular data. Correct use of <caption>, <thead>, <th scope>, and <td headers> is the difference between an accessible table and a grid of unrelated cells.

When <table> is the right element

Use <table> when you have data with a meaningful row-and-column relationship: each cell relates to its row’s identifier and its column’s identifier. Pricing matrices, browser compatibility charts, sports league standings, financial statements.

Do not use <table> for visual layout. CSS Grid and Flexbox (/css/layout-primitives) replace every historical layout-table use case. Layout tables harm screen-reader users (who hear “table with N rows” announcements that are meaningless) and break responsive design.

About 6% of pages on the HTTP Archive 2024 dataset still use <table> for layout, down from 32% in 2014.

Required structure

A robust data table:

<table>
  <caption>Browser engine support, Q1 2025</caption>
  <thead>
    <tr>
      <th></th>
      <th scope="col">Chromium</th>
      <th scope="col">Gecko</th>
      <th scope="col">WebKit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">CSS :has()</th>
      <td>Yes</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <th scope="row">View transitions</th>
      <td>Yes</td>
      <td>Yes</td>
      <td>Partial</td>
    </tr>
  </tbody>
</table>

The pieces that matter:

  • <caption> — the table’s name. Announced first by screen readers. Always provide.
  • <thead> and <tbody> — improve programmatic structure and enable repeating header rows during print.
  • <th scope="col"> — declares “this is a column header”. The column header text is then associated with each <td> below it.
  • <th scope="row"> — declares “this is a row header”. Useful when each row is identified by its first cell.
  • <th> is not the same as <td>. <th> exposes role="rowheader" or role="columnheader".

Cells with multiple headers

When a cell relates to more than one row or column header (common in financial statements), use headers and id:

<table>
  <caption>Revenue by region and quarter</caption>
  <tr>
    <td></td>
    <th id="q1">Q1</th>
    <th id="q2">Q2</th>
  </tr>
  <tr>
    <th id="emea">EMEA</th>
    <td headers="emea q1">42M</td>
    <td headers="emea q2">48M</td>
  </tr>
</table>

The headers attribute references ids; assistive technology announces both header names plus the cell value.

Sortable tables

For sortable column headers, add <button> inside the <th>:

<th scope="col" aria-sort="ascending">
  <button type="button">
    Year <span aria-hidden="true">▲</span>
  </button>
</th>

aria-sort accepts none, ascending, descending, other. Set on the <th> (not the <button>) so screen readers announce the column’s current sort state on every cell.

Responsive patterns

Tables resist responsive design because their structure assumes horizontal real estate. Three common approaches:

  1. Horizontal scroll. Wrap the table in a container with overflow-x: auto and tabindex="0". Pros: structure preserved. Cons: thumb scroll on mobile is awkward.
  2. Stacked rows. On narrow viewports, each row becomes a key-value list. Implementations vary in accessibility; the well-tested pattern uses CSS only with data-label attributes on cells.
  3. Disclosure rows. Show only the most important columns; reveal the rest in an expandable detail row. Implemented with <details> inside <td> or with JavaScript.

About 35% of responsive sites surveyed in 2024 use horizontal scroll, 50% use stacked rows, and 15% use disclosure rows.

Common pitfalls

  • <table> for layout. Use CSS Grid.
  • Missing <caption>. The table has no announced name.
  • Missing scope on <th>. Some engines guess; behaviour is inconsistent. Always specify.
  • <thead> for visual styling only. If the row contains data, not headers, do not wrap in <thead>.
  • role="grid" on a static data table. role="grid" adds an expectation of a 2-D keyboard model (arrow-key navigation cell to cell); add it only if you implement that model.
  • Sticky header row without position: sticky on th. Use thead th { position: sticky; top: 0; } to keep the header visible during long-table scroll.

Cross-engine support

<table> and its descendants reached interop two decades ago. Modern features:

  • position: sticky on <th> reached interop in 2017 across Chromium, WebKit, and Gecko.
  • aria-sort mappings reached interop in 2018.
  • display: contents on <tr> (used in CSS-led restructuring) reached interop in 2020 but disables the row’s role in some engines; use carefully.

Further reading