Tables and data
Tabular data deserves a table — and a table deserves headers. Why the markup around the numbers decides whether anyone can read them.
A table is for data in two dimensions
Rows and columns, where a cell only means something in relation to its row header and its column header. If you cannot say what the two axes are, you do not have tabular data — you have a layout, and a table is the wrong tool for it. That mistake is decades old and still worth naming: a page laid out with tables cannot reflow on a phone, cannot be navigated, and tells a screen reader to announce meaningless coordinates.
Used properly, a table is one of the better pieces of HTML: the browser can tell a reader "Rating, column 3 of 4" for every cell, but only if you say which cells are headers.
The parts
<table>
<caption>Runs this week</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Distance</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monday</td>
<td>5 km</td>
</tr>
</tbody>
</table>What each part is for
<caption>— what the table is, placed before it. The first thing a screen reader announces.<thead>and<tbody>— the header row and the data rows, separated so the header can be repeated on every printed page and announced as a heading when the table is read.<th scope="col">— a header cell for everything below it.scope="row"for a header cell on the left of its row.<td>— an ordinary data cell, which inherits its meaning from the headers.<tfoot>— totals or notes. Placed with the other rows in the source; the browser renders it last.
Headers have a direction
A header cell is not just a cell with bolder text. <th> plus scope says which cells it describes: scope="col" covers the column below it, scope="row" covers the row beside it. A screen reader uses that relationship to announce a runner’s name and time together, instead of reading a number with no context.
The first column often needs headers too. In a timetable, the times down the left are row headers and the days across the top are column headers; mark both, and every cell has an address a listener can follow. If a table has two header rows, scope="colgroup" names the group rather than a single column. A <th> with no scope still looks like a header, but the relationship is left for the browser to guess; scope writes it down.
How a cell is announced
A screen reader does not read a table as a grid of coordinates; it reads a cell together with the headers that describe it. With the markup above, a cell in the body is announced as "Distance, Monday, 5 km" — the caption first, then the column header, then the row header, then the value. Take the <th> elements away and the same cell becomes "Monday, 5 km", which is a fact with no label. That is the whole argument for headers: they turn a position into a sentence.
A table nobody has to guess at
Add a caption, wrap the header row in a thead, and give every header cell scope="col".
3 left to do
- The table has a caption
- The header row is wrapped in a thead
- The header cells declare their scope
The header row is the first <tr> — the one with <th> cells in it.
Spanning and its cost
colspan and rowspan merge cells — useful for a grouped header row, and the fastest way to make a table unreadable when overdone. If a cell covers two columns, a reader loses the one-to-one mapping between a cell and its column header. Use them for the header, spare them in the body.
A worked case shows the cost. A header row with <th colspan="2">Weekend</th> above Saturday and Sunday still makes sense on screen: one label covers two columns. When a body cell spans two columns, the reader has to hold two headers in mind at once, and a listener hears a value whose column is no longer obvious. Group headers with a span; keep the body one cell per column.
Long tables and the parts that travel
<thead>, <tbody> and <tfoot> are not required for a table to render, but they give the browser something to work with: a header row that can be repeated when a long table is printed, and a body that can be told apart from its headings. A table with only <tr> children is a table where every row looks equally like a header. A long table can even hold more than one <tbody>, one per group of rows, which gives each group a boundary without inventing a second table.
<tfoot> is written after the body in the source even though it is shown last. That is deliberate: the totals belong with the rows they sum, and the browser places them at the end. Keep the caption first, and keep the order of the source the order of the meaning.
Three table mistakes that cost a reader the most: using <td> for the header row, so nothing is announced as a heading; leaving out <caption>, so the table arrives with no name; and overusing colspan, which breaks the one-to-one link between a cell and its column. Real headers make a table easier to scan, print and listen to.
Try these next
- Turn the first column of a table into
<th scope="row">and read a row aloud in order. - Add a
<tfoot>with a total and check where the browser shows it. - Give a table two header rows and use
scope="colgroup"on the upper one.
A timetable with two axes
The timetable has days across the top and times down the side, but every cell is a `<td>`. Add a caption, wrap the header row in a thead, and turn the day names into column headers and the time cells into row headers.
4 left to do
- The table has a caption
- The header row is wrapped in a thead
- The first cell of each row is a header
- The header cells declare their scope
The day names become <th scope="col">; each time becomes <th scope="row"> in place of its <td>.
Never use a table to lay out a page. Layout is not markup’s job — the table element is for data, where the rows and the columns are the meaning.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What makes data worth a <table>?
