Tables for data
HTML tables used for real data. Price lists, timetables, comparison charts – not page layout. You already know the basic tags from the beginner series. Here we focus on captions, header scopes, and the markup screen readers need when a table gets more complicated than two columns and a handful of rows.
Tables are for tabular data
If you are lining up a photo gallery or a site footer, use CSS Grid or Flexbox. Reserve <table> for information that genuinely belongs in rows and columns. When the data fits a grid, a table is the right semantic choice – and that semantics matter for accessibility tools and for anyone trying to make sense of a dense page.
Start with a caption
The <caption> element gives the table a visible title. It is the first child inside <table>. Browsers render it above the grid by default, and screen readers usually announce it when someone enters the table.
<table>
<caption>Monthly savings - January to March 2026</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Savings</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">January</th>
<td>£120</td>
</tr>
<tr>
<th scope="row">February</th>
<td>£95</td>
</tr>
<tr>
<th scope="row">March</th>
<td>£140</td>
</tr>
</tbody>
</table>
Write the caption as a short, plain summary. ‘Table 1’ helps nobody. ‘Opening hours for the Manchester workshop’ tells everyone what they are looking at.
Group the structure with thead, tbody, and tfoot
You met these in the intro tutorials. At intermediate level, use them consistently:
<thead>– column (or row) headers.<tbody>– the main data rows.<tfoot>– totals, notes, or summary rows.
Grouping does not change how the table looks on its own, but it helps browsers, assistive tech, and future-you when the markup gets long.
The scope attribute
Header cells (<th>) should declare what they header. The scope attribute does that explicitly:
scope="col"– this header labels the column below it.scope="row"– this header labels the row to its right.scope="colgroup"orscope="rowgroup"– the header applies to a whole group of columns or rows (useful with<colgroup>and<rowgroup>).
In a simple table with one header row and one header column, scope is usually enough. Screen readers can then read ‘Savings, March, £140’ instead of a meaningless string of cell values.
<table>
<caption>Team availability this week</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Mon</th>
<th scope="col">Tue</th>
<th scope="col">Wed</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Alex</th>
<td>Office</td>
<td>Remote</td>
<td>Office</td>
</tr>
<tr>
<th scope="row">Sam</th>
<td>Remote</td>
<td>Remote</td>
<td>Leave</td>
</tr>
</tbody>
</table>
Complex headers: headers and id
When a table has merged cells, stacked header rows, or headers that span several columns, scope alone can get awkward. The alternative is to give each header cell a unique id, then point data cells at the relevant headers with the headers attribute.
Here is a small timetable with two header rows – days across the top, morning and afternoon sessions underneath:
<table>
<caption>Workshop timetable - April 2026</caption>
<thead>
<tr>
<th id="room" rowspan="2" scope="rowgroup">Room</th>
<th id="mon" colspan="2" scope="colgroup">Monday</th>
<th id="tue" colspan="2" scope="colgroup">Tuesday</th>
</tr>
<tr>
<th id="mon-am" scope="col" headers="mon">AM</th>
<th id="mon-pm" scope="col" headers="mon">PM</th>
<th id="tue-am" scope="col" headers="tue">AM</th>
<th id="tue-pm" scope="col" headers="tue">PM</th>
</tr>
</thead>
<tbody>
<tr>
<th id="studio-a" scope="row" headers="room">Studio A</th>
<td headers="studio-a mon mon-am">HTML</td>
<td headers="studio-a mon mon-pm">CSS</td>
<td headers="studio-a tue tue-am">JavaScript</td>
<td headers="studio-a tue tue-pm">Project</td>
</tr>
</tbody>
</table>
Yes, it is verbose. That is the point – you are building an explicit map between headers and cells. Assistive tech can follow those relationships even when the visual layout relies on colspan and rowspan.
When to use which approach
- Simple grid – one header row, plain data cells:
scope="col"on column headers is fine. - Row labels in the first column – add
scope="row"on those row header cells. - Multiple header levels or merged cells – combine
id,headers, andscopeas needed.
If you are unsure whether your table is understandable, open it in a screen reader or run it through an accessibility checker. Confused header announcements usually mean the relationships are not wired up yet.
A few practical habits
- Keep tables as narrow as the content allows. Wide tables are hard on small screens even with horizontal scrolling.
- Align numbers consistently (often right-aligned in CSS) so columns are easy to scan.
- Do not leave empty
<th>cells unless they genuinely mean ‘no heading’. A blank corner cell in a cross-tab table is fine; a missing row label is not. - JavaScript sorting and filtering libraries are fine, but the underlying HTML should still make sense with scripts turned off.
Good data tables are boring in the best way – clear headers, a caption that sets context, and relationships a machine can follow. Get those right and styling becomes the easy part.

