Tables
Tables for tabular data: rows and cells, header cells with scope, captions, grouping with thead, tbody and tfoot, merged cells, and why tables are not for layout.
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"andscope="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:
<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: collapsemerges the borders of neighboring cells into single lines (the default,separate, draws a border around every cell with a gap between them). -
paddingonth, tdgives the values room to breathe. -
text-align: rightandfont-variant-numeric: tabular-numson 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.