HTML sticky table header

Sticky goes on the th cells, not on thead. That one detail is why most attempts do nothing at all.

To build an HTML sticky table header, put position: sticky and top: 0 on the <th> cells.

thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: #16181d;
}

Not on <thead>, and not on the <tr>. Table section and row elements do not take positioning in most engines, so the rule is accepted, ignored, and reports nothing.

A long table scrolled down, with the header row still visible above the data.
A long table scrolled down, with the header row still visible above the data.

Why an HTML sticky table header on thead does nothing

display: table-header-group and display: table-row are not positioned boxes. The specification allows positioning on table cells, and that is the level browsers implement consistently.

The practical rule: sticky lives on th and td, never on the elements that contain them.

If your header spans two rows, put the rule on every th in both, and give the second row a top equal to the first row's height.

thead tr:first-child th { top: 0; }
thead tr:nth-child(2) th { top: 2.25rem; }

The disappearing borders

This is the second surprise. With border-collapse: collapse, borders belong to the table, not to the cells, so the header's bottom border stays behind while the cell travels.

Two fixes, and the second is usually cleaner.

table { border-collapse: separate; border-spacing: 0; }
thead th { border-bottom: 1px solid #2c2f36; }
table { border-collapse: collapse; }
thead th { box-shadow: inset 0 -1px 0 #2c2f36; }

The shadow version keeps collapsed borders everywhere else in the table, which matters if the body cells already have a grid of lines you do not want to redraw.

The background is not optional either. A transparent header lets the data rows scroll visibly through the column names.

Page scroll or container scroll

Page scrolls Wrapper div scrolls
Sticks to Top of the window Top of the wrapper
Needs a height No Yes, max-height
Works with a page header Needs top offset Yes, independently
Horizontal scrolling for wide tables Whole page moves Only the table moves
Better on a phone No Yes

The wrapper version:

.table-wrap { max-height: 70vh; overflow: auto; }

It is the better default for a report page. The surrounding text stays where the reader left it, and a wide table scrolls sideways on its own instead of dragging the whole layout.

Give the wrapper a tabindex of zero so the scroll region can be reached and moved with the keyboard. A scrolling box that only responds to a mouse wheel hides rows from anyone not using one.

<div class="table-wrap" tabindex="0" role="region" aria-label="Regional revenue">

The role and label together mean a screen reader announces what the scrollable area contains before the reader enters it.

Watch the interaction with a page level sticky header. If the page has one and the table scrolls with the page, the table header needs top set to the page header height, not zero.

A table inside a bounded box, scrolling within it while the page text stays still.
A table inside a bounded box, scrolling within it while the page text stays still.

Sticking the first column as well

tbody th, tbody td:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  background: #16181d;
}
thead th:first-child {
  z-index: 3;
}

The z-index ladder is what makes this work. The corner cell belongs to both the sticky row and the sticky column, so it needs the highest value or it is painted over as the table scrolls diagonally.

Use <th scope="row"> for the first column rather than <td>. It sticks the same way and it makes each row announce its own label, which is the whole point of a frozen first column.

Column widths that stop moving

A sticky header makes an old table problem visible. With table-layout: auto the browser sizes columns from the content, so a long value in row 400 widens a column and the header no longer lines up with it.

table { table-layout: fixed; width: 100%; }
th:nth-child(1) { width: 12rem; }
th:nth-child(2) { width: 6rem; }

With fixed layout the widths come from the first row only, which is the header, so the header is by definition in charge.

The cost is that long values no longer expand their column. Decide what happens to them:

td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

Truncating is right for identifiers and paths. It is wrong for anything the reader has to read, so wrap those instead and let the row get taller.

Right align numeric columns and use tabular figures, so digits line up down the column:

td.num { text-align: right; font-variant-numeric: tabular-nums; }

Row headers and scope

While you are here, two attributes make a large table usable with a screen reader.

<th scope="col">Region</th>
<th scope="row">North</th>

scope tells assistive technology which cells a header governs, so a value read out in the middle of the table is announced with its column and row names. A sticky header solves this visually for sighted readers; scope solves it for everyone else.

Add a <caption> as the first child of the table. It is the table's accessible name and it prints.

Printing a long table

Sticky does nothing in print. Use the table specific rules instead:

@media print {
  thead { display: table-header-group; }
  tr { break-inside: avoid; }
  .table-wrap { max-height: none; overflow: visible; }
}

display: table-header-group repeats the header on every printed page, which is the print equivalent of sticking it. Resetting the wrapper is what stops only the visible rows printing. More in print stylesheets.

Checking it

The first column frozen at the left edge while the table is scrolled sideways.
The first column frozen at the left edge while the table is scrolled sideways.

Open the file in the HTML file opener and scroll to the last row. The header should still be there with its border intact.

Then narrow the window until the table is wider than the screen and scroll sideways. Then open the print preview and confirm the header repeats.

When it holds up, paste the HTML into a NOS document. The table renders with the sticky rules intact at the document's own address, so the reader clicks a link and gets the working table rather than a spreadsheet export.

If the reader should be changing the numbers rather than only reading them, editable tables covers that, and turning a table into a link covers getting it out of a spreadsheet in the first place.

Questions people ask

How do I make a table header sticky in HTML?

Apply position sticky and top zero to the th elements, not to thead or tr. Table section and row elements do not accept positioning in most engines, so the rule is ignored without any error.

Why do the borders disappear on my sticky header?

With border-collapse set to collapse, the borders belong to the table rather than the cell, so they do not travel with the sticky cell. Use border-collapse separate, or draw the line with a box-shadow on the th.

Can I stick both the header row and the first column?

Yes. Give the th cells top zero, the first column cells left zero, and the corner cell both plus a higher z-index so it stays above the row and column as they scroll.

Does a sticky header work inside a scrolling div?

Yes, and it sticks to the top of that div rather than the page. The div needs a fixed max-height and overflow auto. This is often better than page scrolling because the surrounding content stays in place.

Keep reading