---
title: Tables
source: 'https://academia.sh/en/courses/web-fundamentals-and-html/tables'
course: 'Web Fundamentals and HTML'
language: en
updated: '2026-08-17T18:09:31+00:00'
license: 'CC BY-SA 4.0'
---

# Tables

Table elements' structure, header cells' scope, building the grid with colspan and rowspan, and computing a data cell's headers.

The previous lesson connected the document to other resources. This lesson takes up the
topic's last structure: two-dimensional data.

Measurement records are not a list. Every row has more than one field, every field has a
heading, and a cell's meaning comes from both its row's and its column's heading. The table
element declares this two-dimensional relationship.

## When a Table Is Used

The criterion is single: is the data two-dimensional? If every cell's meaning comes from the
intersection of a row heading and a column heading, the structure is a table.

Using table elements to build page layout is a separate subject and is not done: layout is the
presentation layer's job, and when a table element is used, a screen reader announces the
content as a row-and-column relationship — a data relationship that does not exist gets
declared.

## Basic Structure

```html
<table>
  <caption>North Slope station's weekly measurement summary</caption>
  <thead>
    <tr><th scope="col">Day</th><th scope="col">Low (°C)</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">Monday</th><td>-4</td></tr>
    <tr><th scope="row">Tuesday</th><td>-6</td></tr>
  </tbody>
  <tfoot>
    <tr><th scope="row">Average</th><td>-5</td></tr>
  </tfoot>
</table>
```

`caption` is the table's name and is written as the table's first child. It declares what the
table shows; a screen reader announces this text when entering the table, so it names the
table outside its context. A heading written as a separate paragraph above the table does not
do this job: there is no declared bond between it and the table.

`thead`, `tbody`, and `tfoot` split the table into three sections. Even if `tbody` is not
written, the parser builds it. The reason for writing them is that in long tables the header
row is separated from the data: this separation gives information both to the presentation
layer and to tools that process the table by section.

`tr` is a row, `th` a header cell, `td` a data cell. The difference between `th` and `td` is
not appearance: a header cell names the data cells associated with it.

## A Header's Scope

The `scope` attribute declares which direction of cells a header cell names: `col` its column,
`row` its row. In simple tables, this declaration does not leave the cell–header match to
guesswork.

Column headers are found inside `thead`, row headers in each row's first cell. Both can be
present at once — in the example above, `Monday` is a row header, `Low` a column header, and
the `-4` cell is associated with both.

## The Grid and Spanning

A table is written row by row in the source text but is really a **grid**. The `colspan` and
`rowspan` attributes let a cell cover more than one grid slot, and in that case the cell count
in the source text and the slot count in the grid diverge.

The script below builds the grid and computes every data cell's headers.

```js
// tables.mjs — builds the grid with colspan/rowspan, finds each cell's headers
const rows = [
  [{ h: 1, t: "Day", rowspan: 2 }, { h: 1, t: "Temperature (°C)", colspan: 2 }, { h: 1, t: "Humidity (%)", rowspan: 2 }],
  [{ h: 1, t: "Low" }, { h: 1, t: "High" }],
  [{ h: 1, t: "Monday" }, { t: "-4" }, { t: "3" }, { t: "72" }],
  [{ h: 1, t: "Tuesday" }, { t: "-6" }, { t: "1" }, { t: "68" }],
];

const grid = [];
const place = (r, c, cell) => {
  grid[r] ??= [];
  grid[r][c] = cell;
};

let rowNo = 0;
for (const row of rows) {
  let colNo = 0;
  for (const cell of row) {
    while (grid[rowNo]?.[colNo]) colNo += 1; // skip occupied spot
    const width = cell.colspan ?? 1;
    const height = cell.rowspan ?? 1;
    for (let r = 0; r < height; r += 1) {
      for (let c = 0; c < width; c += 1) place(rowNo + r, colNo + c, cell);
    }
    colNo += width;
  }
  rowNo += 1;
}

console.log("--- grid ---");
for (const row of grid) {
  console.log(row.map((h) => (h.h ? "[" + h.t + "]" : h.t).padEnd(20)).join("").trimEnd());
}

function headers(r, c) {
  const rowHeaders = [];
  for (let i = c - 1; i >= 0; i -= 1) {
    const h = grid[r][i];
    if (h?.h && !rowHeaders.includes(h.t)) rowHeaders.unshift(h.t);
  }
  const colHeaders = [];
  for (let i = r - 1; i >= 0; i -= 1) {
    const h = grid[i][c];
    if (h?.h && !colHeaders.includes(h.t)) colHeaders.unshift(h.t);
  }
  return [...rowHeaders, ...colHeaders];
}

console.log("--- data cells' headers ---");
for (let r = 2; r < grid.length; r += 1) {
  for (let c = 0; c < grid[r].length; c += 1) {
    const cell = grid[r][c];
    if (cell.h) continue;
    console.log(cell.t.padStart(3), "<-", headers(r, c).join(" / "));
  }
}
```

```
--- grid ---
[Day]               [Temperature (°C)]  [Temperature (°C)]  [Humidity (%)]
[Day]               [Low]               [High]              [Humidity (%)]
[Monday]            -4                  3                   72
[Tuesday]           -6                  1                   68
--- data cells' headers ---
 -4 <- Monday / Temperature (°C) / Low
  3 <- Monday / Temperature (°C) / High
 72 <- Monday / Humidity (%)
 -6 <- Tuesday / Temperature (°C) / Low
  1 <- Tuesday / Temperature (°C) / High
 68 <- Tuesday / Humidity (%)
```

In the source structure, the first row carries three cells, the second row two; in the grid,
though, every row is four slots. The `Day` and `Humidity (%)` headers span two rows, the
`Temperature (°C)` header spans two columns, and the same cell appears in more than one slot
in the grid.

The output's second section shows the real information the markup carries. The value `-4` is
meaningless on its own; together with the three headers it is associated with, it means "the
low temperature value on Monday." Screen readers announce these headers when a cell is
entered; this is how the user tracks where they are in the row and column.

Here is the markup's counterpart:

```html
<tr>
  <th scope="col" rowspan="2">Day</th>
  <th scope="colgroup" colspan="2">Temperature (°C)</th>
  <th scope="col" rowspan="2">Humidity (%)</th>
</tr>
<tr>
  <th scope="col">Low</th>
  <th scope="col">High</th>
</tr>
```

The `colgroup` scope value declares that the header names not a single column but a group of
columns; its row-direction counterpart is the `rowgroup` value.

## Explicit Association in Complex Tables

The computation above relies on adjacency and gives the correct result when headers are placed
regularly. In irregular tables — headers in the middle of the table or at more than one level —
this guess is not enough. In this case the association is declared explicitly: every header
cell is given an `id`, and every data cell lists the identifiers of the headers it is
associated with in the `headers` attribute.

```html
<table>
  <tr>
    <th id="day" rowspan="2">Day</th>
    <th id="temp" colspan="2">Temperature (°C)</th>
  </tr>
  <tr>
    <th id="temp-low">Low</th>
    <th id="temp-high">High</th>
  </tr>
  <tr>
    <th id="day-mon" headers="day">Monday</th>
    <td headers="day-mon temp temp-low">-4</td>
    <td headers="day-mon temp temp-high">3</td>
  </tr>
</table>
```

Explicit association is expensive to maintain: every cell lists its own headers, and
identifiers have to be unique within the document. This cost is paid only when the structure
cannot be resolved by adjacency. A better solution in most cases is splitting the table — a
complex table, once split into two simple tables, becomes easier both to write and to read.

## Column Groups

The `colgroup` and `col` elements are for giving attributes to columns in bulk and are written
at the start of the table, after the `caption` element. They carry no content; they name the
columns in the grid. Their use relates to the presentation layer and does not establish a data
association; they do not stand in for cell headers.

## In the Station Document

```html
<h2>Weekly Summary</h2>
<table>
  <caption>North Slope station, daily extremes over the last seven days</caption>
  <thead>
    <tr>
      <th scope="col" rowspan="2">Day</th>
      <th scope="colgroup" colspan="2">Temperature (°C)</th>
      <th scope="col" rowspan="2">Humidity (%)</th>
    </tr>
    <tr>
      <th scope="col">Low</th>
      <th scope="col">High</th>
    </tr>
  </thead>
  <tbody>
    <tr><th scope="row">Monday</th><td>-4</td><td>3</td><td>72</td></tr>
    <tr><th scope="row">Tuesday</th><td>-6</td><td>1</td><td>68</td></tr>
  </tbody>
</table>
```

## Summary

- A table is for two-dimensional data where a cell's meaning comes from the intersection of
  row and column headers; it is not used for page layout.
- `caption` names the table and is bound to it; a separate paragraph written above it does not
  establish this bond.
- The difference between `th` and `td` is not appearance, it is function; `scope` declares
  which direction of cells a header names.
- With `colspan` and `rowspan`, the cell count in the source text separates from the slot
  count in the grid; the same cell appears in more than one slot.
- In structures that cannot be resolved by adjacency, explicit association is established with
  `headers` and `id`; because maintenance cost is high, splitting the table is considered
  first.

## Next Step

This topic built the document's skeleton: root structure, syntax, headings, text, lists,
links, and tables. Everything written in the document is a structural declaration, but these
declarations do not yet name the document's **sections**: where does navigation end, where
does the main content begin, which one is the footer? The next topic takes up the sectioning
tags that answer this question and builds the document's navigation structure.
