Simple tables work almost by themselves. As soon as a table has headers on two sides, grouped columns or merged cells, you need to tell the browser exactly which headers belong to which cells. Otherwise the table is readable on screen but confusing, or wrong, when read aloud.
caption: the table's title
caption names the table. It must be the first child of table:
<table>
<caption>Class prices, November 2026</caption>
...
</table>
A caption is visible to everyone, and screen readers announce it when entering the table, so people know whether it is worth exploring. It is better than a heading or paragraph above the table, because it is programmatically tied to the table.
The caption can hold a short summary to help people understand a complex table:
<caption>
Class prices, November 2026
<small>Prices per person. Members save 10%.</small>
</caption>
You can move the caption below the table with CSS caption-side: bottom without changing the markup order.
scope: which cells a header applies to
The scope attribute on a th states the direction of the header:
| Value | Header applies to |
|---|---|
col | The cells below it in the same column |
row | The cells to its right in the same row |
colgroup | All the columns it spans |
rowgroup | All the rows in its row group |
<table>
<caption>Opening hours</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Opens</th>
<th scope="col">Closes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monday</th>
<td>7am</td>
<td>4pm</td>
</tr>
<tr>
<th scope="row">Sunday</th>
<td>Closed</td>
<td>Closed</td>
</tr>
</tbody>
</table>
Browsers can often guess the direction in a table this simple, but scope removes the guesswork. Make it a habit on every th.
colspan and rowspan
A cell can stretch across several columns with colspan, or several rows with rowspan:
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<title>Class timetable</title>
<style>
table { border-collapse: collapse; }
th, td { border: 1px solid #888; padding: 0.4rem 0.75rem; text-align: left; }
</style>
</head>
<body>
<table>
<caption>Weekend class timetable</caption>
<thead>
<tr>
<td></td>
<th scope="colgroup" colspan="2">Saturday</th>
<th scope="colgroup" colspan="2">Sunday</th>
</tr>
<tr>
<td></td>
<th scope="col">Morning</th>
<th scope="col">Afternoon</th>
<th scope="col">Morning</th>
<th scope="col">Afternoon</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Sourdough</th>
<td>Basics</td>
<td>Advanced</td>
<td colspan="2">Full-day masterclass</td>
</tr>
<tr>
<th scope="row">Pastry</th>
<td>Croissants</td>
<td>Danish</td>
<td>Croissants</td>
<td>No class</td>
</tr>
</tbody>
</table>
</body>
</html>
"Saturday" and "Sunday" each cover two columns, so they use colspan="2" and scope="colgroup". The Sunday masterclass covers both Sunday slots, so its td uses colspan="2". A screen reader reaching "Danish" can announce "Saturday, Afternoon, Pastry, Danish".
The top-left cell is an empty td, not a th, because it is not a header for anything.
Counting columns
With spanning cells, every row must still add up to the same number of columns. In the timetable, each row is five columns wide: the row header plus four slots, with a colspan="2" cell counting as two.
When a cell spans rows with rowspan, it occupies a slot in the rows below, so those rows contain one fewer cell in the markup:
<table>
<caption>Shop staff rota</caption>
<thead>
<tr>
<th scope="col">Shift</th>
<th scope="col">Day</th>
<th scope="col">Staff</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" rowspan="2">Early</th>
<td>Monday</td>
<td>Priya</td>
</tr>
<tr>
<td>Tuesday</td>
<td>Tom</td>
</tr>
</tbody>
</table>
The second body row has only two cells, because "Early" already fills its first column.
When a spanning table looks wrong in the browser, cells have shifted because a row has too many or too few. Sketch the grid on paper and count each row, including the slots taken by rowspan cells from above.
colgroup and col
colgroup and col describe columns as a whole. They go after the caption and before thead. Their main practical use is styling whole columns with a few CSS properties, such as width and background:
<style>
.figures { background: #f2f6fa; }
</style>
<table>
<caption>Loaves sold</caption>
<colgroup>
<col>
<col span="2" class="figures">
</colgroup>
<thead>
<tr>
<th scope="col">Loaf</th>
<th scope="col">September</th>
<th scope="col">October</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Sourdough</th>
<td>412</td>
<td>455</td>
</tr>
</tbody>
</table>
Only a handful of CSS properties work on columns: background, border, width and visibility. For other styling, target the cells.
The headers attribute
For irregular tables where scope cannot express the relationships, a td can list the id values of its header cells:
<table>
<caption>Saturday classes</caption>
<tr>
<th id="sat" colspan="2">Saturday</th>
</tr>
<tr>
<th id="am">Morning</th>
<th id="pm">Afternoon</th>
</tr>
<tr>
<td headers="sat am">Basics</td>
<td headers="sat pm">Advanced</td>
</tr>
</table>
It works, but it is laborious and easy to break when the table changes. If you find yourself needing headers throughout a table, the table is probably too complex.
When to split a table
Complex tables are hard for everyone, not just screen reader users. Before reaching for multi-level headers, ask whether two or three simpler tables, each with its own caption, would be clearer. They often are, and they are much easier to make responsive.
Large tables are difficult on small screens. The Responsive Web Design course covers techniques such as scrolling containers and alternative layouts for narrow viewports.
Summary
- Give every data table a
captionas its first child. - Add
scope="col"orscope="row"to everyth, andcolgrouporrowgroupfor grouped headers. - Use
colspanandrowspanfor merged cells, and make every row add up to the same number of columns. - Use the
headersattribute only for genuinely irregular tables. - If a table needs many levels of headers, consider splitting it.