What tables are for

An HTML table is the <table> element and its contents: a grid of rows and columns in which every cell belongs to one row and one column. It is meant for tabular data, information whose meaning depends on its position. "312" means nothing on its own; in the row "March" under the column "Electricity (kWh)", it is a meter reading.

Good candidates for a table: a price list with several tariffs, a timetable, monthly usage figures, a comparison of plans, test results. Poor candidates: a list of links (use a list), a form (use labels and fields), or anything where you only want things to sit side by side (use CSS). If you could read the content aloud without saying "row" or "column", it is probably not a table.

An HTML table is a way to show data. It is related to, but not the same as, a table in a relational database, where rows and columns are a way to store data. A web page often shows the result of a database query as an HTML table.

Rows and cells

HTML describes a table row by row. Each <tr> (table row) contains cells: <td> (table data) for values and <th> (table header) for headings. There is no element for a column: a column is simply every cell in the same position across the rows.

<table>
  <tr>
    <th>Month</th>
    <th>Electricity (kWh)</th>
    <th>Gas (m³)</th>
  </tr>
  <tr>
    <td>January</td>
    <td>341</td>
    <td>212</td>
  </tr>
  <tr>
    <td>February</td>
    <td>298</td>
    <td>187</td>
  </tr>
  <tr>
    <td>March</td>
    <td>312</td>
    <td>140</td>
  </tr>
</table>

Without any CSS, a browser shows header cells bold and centered, data cells left-aligned, and no borders at all. Every row should have the same number of cells (counting merged cells by the columns they cover); if a value is missing, keep an empty <td></td> or write "–" or "n/a", so the following cells do not shift one column to the left.

Header cells and scope

A table header cell is a <th> element: a cell that labels other cells instead of holding a value. Headings are not only for column tops. The first cell of each row is often a heading too: the name of the thing the row describes.

The scope attribute says which cells a header describes:

  • scope="col": the cells below it in the same column.
  • scope="row": the cells to its right in the same row.
  • scope="colgroup" and scope="rowgroup": a group of columns or rows (see merged cells).

Why it matters: a sighted reader connects a value with its headings by looking up and to the left. A screen reader user hears one cell at a time, so the screen reader announces the headings as the user moves: "Variable, Standing charge per day, €0.31". Browsers guess the scope when it is missing, and for a simple grid they usually guess right, but writing it removes the guesswork.

<table>
  <caption>Kestrel Energy electricity tariffs, 2026</caption>
  <tr>
    <th scope="col">Tariff</th>
    <th scope="col">Price per kWh</th>
    <th scope="col">Standing charge per day</th>
  </tr>
  <tr>
    <th scope="row">Fixed, 1 year</th>
    <td>€0.27</td>
    <td>€0.29</td>
  </tr>
  <tr>
    <th scope="row">Variable</th>
    <td>€0.25</td>
    <td>€0.31</td>
  </tr>
</table>
table {
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
}
th, td {
  border: 1px solid #595959;
  padding: 0.4rem 0.75rem;
}
th[scope="row"] {
  text-align: left;
}

The CSS uses an attribute selector, th[scope="row"], to left-align the row headings. Markup that describes the data well also gives you useful hooks for styling.

Captions

A table caption is the <caption> element: the title of a table. It must be the first child of <table>, before any rows. The browser shows it centered above the table (CSS caption-side: bottom moves it below) and uses it as the table's accessible name, so a screen reader user who jumps from table to table hears what each one is about before entering it.

A good caption says what the data is, for whom and for when: "Kestrel Energy electricity tariffs, 2026" rather than "Tariffs" or "Table 1". If the table needs a longer explanation (how figures were rounded, where they come from), put a short summary in the caption or a paragraph just before the table. The old summary attribute on <table> is obsolete.

Head, body and foot

Three elements group rows by their role:

  • <thead>: the row or rows with column headings.
  • <tbody>: the data rows. A table can have several, each a group of related rows.
  • <tfoot>: summary rows such as totals. In current HTML it comes after the bodies, which is also where it is shown.

The order inside <table> is: caption, then optional <colgroup> elements, then <thead>, then the bodies, then <tfoot>. The groups give structure that browsers use when printing (Chrome, Edge and Firefox repeat the head row of a long table on every printed page; Safari does not), that CSS can target, and that assistive technology can report.

<table>
  <caption>Electricity use, first quarter 2026</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">kWh</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>341</td>
    </tr>
    <tr>
      <th scope="row">February</th>
      <td>298</td>
    </tr>
    <tr>
      <th scope="row">March</th>
      <td>312</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Total</th>
      <td>951</td>
    </tr>
  </tfoot>
</table>
table {
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
}
th, td {
  padding: 0.4rem 1rem;
  text-align: left;
}
thead th {
  border-bottom: 2px solid #1a1a1a;
}
tfoot th, tfoot td {
  border-top: 1px solid #1a1a1a;
  font-weight: bold;
}
td {
  text-align: right;
}

The <col> and <colgroup> elements describe columns. They can carry a span attribute and accept only a few CSS properties: background, border (with collapsed borders), width and visibility: collapse. They cannot change text alignment or font; for that, select the cells, for example with td:nth-child(2).

Merged cells: colspan and rowspan

colspan and rowspan are the attributes that let one <td> or <th> cover more than one column (colspan="2") or more than one row (rowspan="2"). Both default to 1. The cells that a merged cell covers are simply left out of the markup: a row under a rowspan="2" cell has one cell fewer.

The typical use is a two-level header. In the outage summary below, "Planned" and "Unplanned" each cover two columns, and "Region" covers both header rows:

A grid of five columns and three rows. The Region cell covers rows 1 and 2 of column 1 (rowspan 2). Planned covers columns 2 and 3 of row 1 and Unplanned covers columns 4 and 5 (colspan 2 each). Row 2 has four cells: Count, Hours, Count, Hours. Row 3 has five normal cells. col 1 col 2 col 3 col 4 col 5 Region rowspan="2" Planned colspan="2" Unplanned colspan="2" Count Hours Count Hours North 3 5 1 2 Row 1 has 3 cells, row 2 has 4 (column 1 is taken by Region), row 3 has 5.
Each merged cell occupies several slots of the grid; the rows below or beside it leave those slots out.
<table>
  <caption>Outages per region, September 2026</caption>
  <colgroup>
    <col>
  </colgroup>
  <colgroup span="2"></colgroup>
  <colgroup span="2"></colgroup>
  <thead>
    <tr>
      <th scope="col" rowspan="2">Region</th>
      <th scope="colgroup" colspan="2">Planned</th>
      <th scope="colgroup" colspan="2">Unplanned</th>
    </tr>
    <tr>
      <th scope="col">Count</th>
      <th scope="col">Hours</th>
      <th scope="col">Count</th>
      <th scope="col">Hours</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>3</td>
      <td>5</td>
      <td>1</td>
      <td>2</td>
    </tr>
    <tr>
      <th scope="row">Coast</th>
      <td>2</td>
      <td>4</td>
      <td>4</td>
      <td>7</td>
    </tr>
  </tbody>
</table>
table {
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
}
th, td {
  border: 1px solid #595959;
  padding: 0.35rem 0.75rem;
}
td {
  text-align: right;
}

The <colgroup> elements are there so that scope="colgroup" has column groups to refer to: the first group is the Region column, the next two groups have two columns each. For tables more tangled than this, each data cell can list the ids of its header cells in a headers attribute (<td headers="north planned-hours">). That works, but it is a sign the table may be easier to understand if you split it into two simpler tables.

Styling tables

A few CSS properties do most of the work for tables:

  • border-collapse: collapse merges the borders of neighboring cells into single lines (the default, separate, draws a border around every cell with a gap between them).
  • padding on th, td gives the values room to breathe.
  • text-align: right and font-variant-numeric: tabular-nums on numeric columns line up the digits, so amounts can be compared at a glance.
  • tbody tr:nth-child(even) selects every second row for subtle "zebra" striping, which helps the eye follow a wide row.
<table>
  <caption>Open invoices</caption>
  <thead>
    <tr>
      <th scope="col">Invoice</th>
      <th scope="col">Due</th>
      <th scope="col" class="num">Amount</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>KE-2026-0412</td><td>2026-10-15</td><td class="num">€84.10</td></tr>
    <tr><td>KE-2026-0413</td><td>2026-10-15</td><td class="num">€1,117.85</td></tr>
    <tr><td>KE-2026-0420</td><td>2026-10-31</td><td class="num">€9.99</td></tr>
    <tr><td>KE-2026-0431</td><td>2026-11-01</td><td class="num">€211.40</td></tr>
  </tbody>
</table>
table {
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
}
caption {
  text-align: left;
  font-weight: bold;
  padding-bottom: 0.4rem;
}
th, td {
  padding: 0.35rem 0.9rem;
  text-align: left;
}
thead th {
  border-bottom: 2px solid #1a1a1a;
}
tbody tr:nth-child(even) {
  background: #f3f4f6;
}
.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

The stripe color is very light, so the dark text keeps a high contrast on both row colors. Striping and borders are for the eye only; the structure that assistive technology uses comes from the elements and scope.

Wide tables on small screens

A table cannot reflow like a paragraph: its columns need their width. On a phone, a wide table either squeezes its text into narrow columns or pushes the whole page sideways. The usual fix is a wrapper that scrolls horizontally, so only the table scrolls and the rest of the page stays put. This site does the same with every table it shows. The frame below is as wide as the content column; make your browser window narrower to see the scrollbar appear.

<div class="table-scroll" role="region" aria-labelledby="usage-caption" tabindex="0">
  <table>
    <caption id="usage-caption">Electricity use per month, 2026 (kWh)</caption>
    <thead>
      <tr>
        <th scope="col">Meter</th>
        <th scope="col">Jan</th><th scope="col">Feb</th><th scope="col">Mar</th>
        <th scope="col">Apr</th><th scope="col">May</th><th scope="col">Jun</th>
        <th scope="col">Jul</th><th scope="col">Aug</th><th scope="col">Sep</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">E-5531</th>
        <td>341</td><td>298</td><td>312</td><td>254</td><td>221</td>
        <td>198</td><td>205</td><td>210</td><td>236</td>
      </tr>
    </tbody>
  </table>
</div>
.table-scroll {
  overflow-x: auto;
  max-width: 100%;
}
.table-scroll:focus-visible {
  outline: 2px solid #1d4ed8;
}
table {
  border-collapse: collapse;
  font-family: system-ui, sans-serif;
  white-space: nowrap;
}
caption {
  text-align: left;
}
th, td {
  border: 1px solid #595959;
  padding: 0.35rem 0.9rem;
}

The wrapper has tabindex="0" so keyboard users can focus it and scroll with the arrow keys, and role="region" with aria-labelledby so a screen reader announces what that focus stop is. This is a common pattern; the accessibility page explains when ARIA attributes like these are appropriate.

Why tables are not for layout

Before CSS could do columns, many sites were built from big nested tables: one cell for the logo, one for the menu, one for the content. That habit lives on in some old intranet pages and templates. It causes real problems:

  • Wrong meaning. Screen readers announce a table, its size and its "headers", and offer table navigation commands. For a layout grid that is confusing noise.
  • No responsiveness. Table columns stay side by side. A layout grid cannot rearrange itself into a single column on a phone without fighting the table.
  • Hard to change. The design is locked into the HTML. Moving the menu means rewriting markup instead of changing a few lines of CSS.

For layout, use CSS Grid (rows and columns) and Flexbox (a row or column of items). Keep tables for data.

The opposite mistake happens too: data that really is tabular shown with <div> elements and CSS Grid. It may look like a table, but without table semantics a screen reader user hears a stream of disconnected values. If the data has rows and columns, use <table> and style it.