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.
<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.
<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"andscope="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.
<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:
.layout {
display: grid;
grid-template-columns: 1fr 300px;
}See css for the grid and flexbox patterns. For ARIA roles that supplement table semantics in custom grid components, see html-aria-patterns.