Course outline · 0% complete

0/30 lessons0%

Course overview →

Tables

lesson 3-2 · ~7 min · 8/30

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>
ElementRole
tra table row
tha header cell, announced as the header for its column
tda data cell
theadgroups the header rows
tbodygroups 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.

tablethead > trtbody > trth Planth Pricetd Plustd $8/motd Premiumtd $20/mo
Table markup is four strict levels deep, and every cell sits inside a row.

The header cell element

The header cell is th.

ElementWhat it is
tha header cell
tda normal data cell
trthe whole row
theadthe 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>
StepCode
pick the cell tag onceconst t = isHeader ? "th" : "td";
wrap each cellcells.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>
GroupContainsCell type
theadone rowth
tbodytwo rowstd

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.

LevelElement
outermosttable
groupingthead or tbody
rowtr
cellth 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.