Keep an HTML table header visible while scrolling

Sticky positioning on the header cells does it in three lines. The failures are never the CSS itself, they are the scroll container, a transparent background, or an overflow value somewhere above the table.

To keep an HTML table header visible while the rows scroll, give the header cells position: sticky and top: 0.

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

Three declarations do the work and one is easy to skip. The background is not styling here. Without it the rows show through the header as they pass beneath.

A long table scrolled down. The column headings are off screen and the columns are unlabelled.
A long table scrolled down. The column headings are off screen and the columns are unlabelled.

Put it on the cells, not the row

Sticky on <thead> works in current browsers. Sticky on <tr> does not, and older engines only ever honoured it on <th> and <td>.

The cell is the safe target. Apply it to thead th and it behaves the same everywhere, including inside a wrapper.

For a two row header, offset the second row by the height of the first:

thead tr:nth-child(1) th { top: 0; }
thead tr:nth-child(2) th { top: 38px; }

The pixel value has to match the real header height. If the header wraps on a narrow screen, that number stops being true, which is one reason two row headers are worth avoiding.

What scrolls decides where it sticks

This is the part that generates most of the confusion. Sticky holds a cell against the nearest ancestor that scrolls, and there are two useful arrangements.

Arrangement How to get it Reader gets
Page scrolls No height on any wrapper Header pins to the top of the window
Wrapper scrolls max-height plus overflow: auto on the wrapper Header pins to the top of the box, page stays still

The second is right for a dashboard where the table is one panel among several. The first is right when the table is the page.

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

If your header sticks to the window when you wanted it inside the box, the wrapper has no height, so it never scrolls and cannot be the container.

The same table scrolled down with the header frozen at the top. Every column is still labelled.
The same table scrolled down with the header frozen at the top. Every column is still labelled.

The three ancestors that break it silently

Nothing warns you. The declaration is valid, it is applied, and the header scrolls away anyway.

  1. overflow: hidden on an ancestor. It makes that element a scroll container that cannot scroll, so the sticky cell has nothing to travel within. Common on layout wrappers added for a different reason.
  2. overflow: clip on an ancestor. Same effect for the same reason.
  3. A fixed height on the table itself. Tables do not honour height the way blocks do, and a constrained table can leave the header nowhere to hold.

Work upwards from the table in the browser inspector until you find the element with an overflow value, and decide whether it needs one.

The background and the stacking order

A sticky header keeps its transparency. If the table is on white and the header has no background, the body rows paint straight through it as they pass.

Set a colour, and set it for both themes if the page has a dark mode. Then add z-index, because without it the header and the body cells are in the same painting layer and later content can win.

A value of 2 on the header is enough for a plain table. When a pinned first column is also present, the corner cell needs a higher value than either.

thead th { z-index: 3; }
tbody th { position: sticky; left: 0; z-index: 2; }
thead th:first-child { left: 0; z-index: 4; }

Borders on a frozen header

Under border-collapse: collapse, the rule under the header belongs to the shared edge between the header and the first body row. That edge does not travel with the sticky cell, so the frozen header can lose its underline as it detaches.

Switch to separate borders with zero spacing and put the rule on the header cell itself:

table { border-collapse: separate; border-spacing: 0; }
thead th { border-bottom: 1px solid #e3e3e3; }

Border collapse covers the trade in full. A box shadow under the header is the other option, and it reads as depth once the table is scrolled.

The alternatives, and when they are better

  • A caption instead. For a table under about fifteen rows, a frozen header is noise. The reader can see the header and the data at once.
  • Repeating headers in print. thead already repeats on every printed page in most browsers. Sticky has no effect on paper. See print stylesheets.
  • Stacking on mobile. Below phone width a frozen header may be a bigger obstruction than help. Responsive tables covers the switch.
A table inside a fixed height panel. The header is frozen at the top of the panel, not the window.
A table inside a fixed height panel. The header is frozen at the top of the panel, not the window.

Checking it and passing it on

Test it in a window that has never seen the rest of your project. The HTML file opener renders the file on its own.

An ancestor overflow rule from a shared stylesheet is the usual cause of a header that worked locally and stopped working once it was in place.

To give the table to someone, paste the HTML into a NOS document. It renders as written, sticky header included, at an address of its own. Share, then Share link, then Create link gives a link that opens in one click.

A screenshot would lose the scrolling that the frozen header exists to support, which is the argument for the link over an image or a PDF. HTML to link is the same route for any page.

Questions people ask

Why does position sticky not work on thead?

In older browsers sticky had to go on the th cells rather than the thead or tr element. Current browsers accept it on thead, but putting it on the th cells still works everywhere, so it is the safer place for it.

My sticky header is transparent and rows show through it. Why?

A sticky element keeps whatever background it had, and a table header usually has none. The rows scroll underneath and paint through. Set an explicit background colour on the header cells and give them a z-index above the body cells.

The header sticks to the page, not to my scrolling box. How do I fix it?

Sticky holds against the nearest scrolling ancestor. If the wrapper around your table has a height and overflow: auto, the header will stick inside that wrapper. If it does not, the header sticks to the window instead. Decide which you want and put the height and overflow accordingly.

Can I keep the header and the first column visible together?

Yes. Apply top: 0 to the header cells and left: 0 to the first cell of each body row. The top left cell needs both offsets and the highest z-index, otherwise it disappears under the header when you scroll sideways.

Keep reading