---
title: "HTML: Tables"
slug: "html-tables"
category: "frontend"
tags: ["frontend", "html", "tables", "accessibility", "semantics"]
status: "stable"
last_updated: 2026-05-14
summary: "Use tables for tabular data only; caption, thead, tbody, tfoot, and th scope make them accessible to screen readers."
related: ["[[frontend/html]]", "[[frontend/accessibility]]", "[[frontend/html-semantic-elements]]", "[[frontend/css]]", "[[frontend/html-aria-patterns]]"]
---

## Overview

Tables are for tabular data: information with a meaningful row-and-column relationship. They are not for layout. Using `<table>` for layout was a 1990s workaround that CSS grid and flexbox replaced completely. This page covers the structural and accessibility rules for data tables.

## Use tables only when data has a genuine row-column relationship

The test: can a user remove any row or column and still have a meaningful table? If yes, the data is tabular. Comparison matrices, financial data, schedules, and specification sheets are good fits. Use `<table>` for them.

Navigation bars, multi-column page layouts, and card grids are not tabular. Use CSS grid or flexbox. A layout table read by a screen reader announces "table, 3 columns, 12 rows" and reads cells in source order; that context is meaningless for visual layout.

## `<caption>` names the table for screen readers

Place `<caption>` as the first child of `<table>`. It is the table's accessible name and appears visually above the data by default.

```html
<table>
  <caption>Q1 2026 Revenue by Region</caption>
  <!-- ... -->
</table>
```

Do not use `aria-label` instead; `<caption>` is the correct native mechanism. If the caption is visually redundant (the heading above already names it), hide it with `.sr-only` rather than removing it.

## `<thead>`, `<tbody>`, and `<tfoot>` structure the table

`<thead>` wraps the header row(s). `<tbody>` wraps the data rows. `<tfoot>` wraps summary rows (totals, averages). Browsers and screen readers use this structure to group and navigate rows.

```html
<table>
  <caption>Q1 2026 Revenue by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Revenue</th>
      <th scope="col">Growth</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">APAC</th>
      <td>$4.2 M</td>
      <td>+12%</td>
    </tr>
    <tr>
      <th scope="row">EMEA</th>
      <td>$3.8 M</td>
      <td>+8%</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Total</th>
      <td>$8.0 M</td>
      <td>+10%</td>
    </tr>
  </tfoot>
</table>
```

## `<th scope>` connects headers to cells

The `scope` attribute tells assistive technology which cells a header applies to.

- `scope="col"`: the header applies to all cells in its column.
- `scope="row"`: the header applies to all cells in its row.
- `scope="colgroup"` and `scope="rowgroup"`: for headers that span multiple columns or rows.

A screen reader reads each cell in a simple table as "Region: APAC, Revenue: $4.2 M" when `scope` is set. Without it, the reader announces bare cell values with no context.

## Complex tables need `headers` and `id` for ambiguous cells

When a single cell has more than one row header and one column header (a multi-level header table), `scope` alone is insufficient. Use `id` on each header and `headers` on each data cell to list all headers that apply.

```html
<thead>
  <tr>
    <th id="region">Region</th>
    <th id="q1">Q1</th>
    <th id="q2">Q2</th>
  </tr>
</thead>
<tbody>
  <tr>
    <th id="apac" scope="row">APAC</th>
    <td headers="q1 apac">$4.2 M</td>
    <td headers="q2 apac">$4.8 M</td>
  </tr>
</tbody>
```

Avoid building complex multi-level header tables where possible. They are hard to understand visually and hard to make accessible. Split them into simpler tables with clear captions.

## `colspan` and `rowspan` require careful scope updates

Every header that spans multiple columns or rows needs a matching `scope="colgroup"` or `scope="rowgroup"`, or explicit `id`/`headers` wiring. Missing scope on a spanning header breaks screen reader cell-header association.

## Never use table layout; use CSS grid or flexbox

If you are reaching for `<table>` to place a sidebar next to a content column, stop. CSS grid does this in two lines:

```css
.layout {
  display: grid;
  grid-template-columns: 1fr 300px;
}
```

See [[frontend/css]] for the grid and flexbox patterns. For ARIA roles that supplement table semantics in custom grid components, see [[frontend/html-aria-patterns]].

## Related

- [[frontend/html]]
- [[frontend/accessibility]]
- [[frontend/html-semantic-elements]]
- [[frontend/css]]
- [[frontend/html-aria-patterns]]
