Tables
Working engineers ship tables constantly, since admin dashboards, pricing pages, and reports are all rows and columns. A screen reader can only read a data grid sensibly when it is marked up as a real table.
Tables are for tabular data, meaning rows and columns where the grid itself carries meaning, such as prices or scores. They were once abused for page layout, and flexbox and grid replaced that, as units 6 and 7 show.
<table> <thead> <tr><th>Plan</th><th>Price</th></tr> </thead> <tbody> <tr><td>Plus</td><td>$8/mo</td></tr> <tr><td>Premium</td><td>$20/mo</td></tr> </tbody> </table>
| Element | Role |
|---|---|
tr | a table row |
th | a header cell, announced as the header for its column |
td | a data cell |
thead | groups the header rows |
tbody | groups the data rows |
th renders bold, and the more important effect is the announcement. Without it, a screen reader reads a grid of numbers with no idea which column each one belongs to.
The header cell element
The header cell is th.
| Element | What it is |
|---|---|
th | a header cell |
td | a normal data cell |
tr | the whole row |
thead | the wrapper grouping header rows |
tr is the row that contains cells rather than a cell itself, and thead is one level further out again. Keeping the three levels straight, meaning table, row, and cell, is most of what makes table markup readable.
Generating a table row from an array
Real apps build table rows from arrays constantly. row(cells, isHeader) wraps each entry in th or td, then wraps the result in tr.
function row(cells, isHeader) { const t = isHeader ? "th" : "td"; const inner = cells .map((cell) => "<" + t + ">" + cell + "</" + t + ">") .join(""); return "<tr>" + inner + "</tr>"; } console.log(row(["Plan", "Price"], true)); console.log(row(["Plus", "$8/mo"], false));
Output
<tr><th>Plan</th><th>Price</th></tr>
<tr><td>Plus</td><td>$8/mo</td></tr>| Step | Code |
|---|---|
| pick the cell tag once | const t = isHeader ? "th" : "td"; |
| wrap each cell | cells.map(...) |
| glue with nothing between | .join("") |
| wrap the row | "<tr>" + inner + "</tr>" |
Choosing the tag name once at the top, rather than branching inside the map, keeps the loop body to a single expression. join("") matters here, because the default join() inserts commas that would appear as visible text between cells.
A pricing table with a real header row
The header row lives in thead with th cells, and the data rows live in tbody with td cells.
HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Plans</title> </head> <body> <h1>Plans</h1> <table> <thead> <tr><th>Plan</th><th>Price</th></tr> </thead> <tbody> <tr><td>Plus</td><td>$8 per month</td></tr> <tr><td>Premium</td><td>$20 per month</td></tr> </tbody> </table> </body> </html>
| Group | Contains | Cell type |
|---|---|---|
thead | one row | th |
tbody | two rows | td |
Every cell sits inside a tr, without exception. The th cells are what tell a screen reader which column a number belongs to, which is the difference between a readable table and a stream of unlabelled values.
Where every cell lives
Every cell, header or data, sits directly inside a tr.
Cells are organized row by row, so th and td always live in a tr, and rows live in the table, usually grouped by thead or tbody.
| Level | Element |
|---|---|
| outermost | table |
| grouping | thead or tbody |
| row | tr |
| cell | th or td |
The nesting is strict, and browsers will silently repair some violations by inserting missing elements. Relying on that repair is unwise, since the repaired structure is not always the one intended.