Keep a table header visible with sticky positioning

The problem

A data table is taller than the space allotted to it, so its rows scroll away and the column titles that make them readable scroll away with them. The header must stay visible while the body scrolls, the table may also need to scroll sideways, and no JavaScript or duplicated header table should be involved. Current Chromium, Firefox and Safari are assumed.

Short answer

Give the table a scrolling ancestor with a definite block size, apply position: sticky with inset-block-start: 0 to the th cells instead of the thead, back each cell with an opaque colour token, and use border-collapse: separate so the header border travels with the cells.

A long table reads badly when its column titles leave the screen. position: sticky keeps the header row in place without the duplication that fixed-position hacks require, but it only works once you state which box it is sticking inside of, and that is the part most attempts get wrong.

Language: HTML
<div class="table-scroll" tabindex="0" role="region" aria-labelledby="orders-caption">
  <table class="data-table">
    <caption id="orders-caption">Open orders, newest first</caption>
    <thead>
      <tr>
        <th scope="col">Order</th>
        <th scope="col">Customer</th>
        <th scope="col" class="num">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>10482</td>
        <td>A. Ferreira</td>
        <td class="num">240.00</td>
      </tr>
      <tr>
        <td>10481</td>
        <td>K. Osei</td>
        <td class="num">1180.50</td>
      </tr>
    </tbody>
  </table>
</div>

Language: CSS
.table-scroll {
  max-block-size: 24rem;
  overflow: auto;
  overscroll-behavior-x: contain;
  border: 1px solid var(--de-field-border, #6b7684);
  border-radius: 0.5rem;
}

.table-scroll:focus-visible {
  outline: 3px solid var(--de-focus-ring, #0b57d0);
  outline-offset: -3px;
}

.data-table {
  inline-size: 100%;
  min-inline-size: 40rem;
  border-collapse: separate;
  border-spacing: 0;
}

.data-table th {
  position: sticky;
  inset-block-start: 0;
  z-index: 1;
  background-color: var(--de-surface, #f4f5f7);
  box-shadow: 0 1px 0 var(--de-border, #c7ccd4);
  padding: 0.5rem 1rem;
  text-align: start;
}

.data-table td {
  padding: 0.5rem 1rem;
  border-block-end: 1px solid var(--de-border, #c7ccd4);
}

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

Explanation

Sticky offsets are resolved against the nearest scrollport, so the rule only means something once a scroll container exists. Here the wrapper is that container: max-block-size: 24rem plus overflow: auto gives it a definite block size and overflowing content, which is what creates the box whose top edge the header pins to. Omit the block size and the wrapper grows to fit the table, there is no vertical scrolling, and the header appears not to stick at all, which is the single most common failed attempt. The other supported shape is a table that scrolls with the page rather than inside a box: in that case you skip the wrapper entirely, set inset-block-start: 0 on the cells, and make sure no ancestor declares overflow: hidden or overflow: auto, because such an ancestor becomes the scrollport and the pin happens inside it instead of at the viewport.

The header cells carry the declarations, not the row group. Sticky support on a thead or a tr was missing in WebKit for years and is still the least reliable place to put it, while sticky on a cell is supported everywhere current. Per-cell sticking also explains the two remaining choices. border-collapse: separate with border-spacing: 0 is used because collapsed borders are computed once for the shared edge between two rows and do not travel with a stuck cell, which is why the bottom rule vanishes the moment the header pins; the box-shadow: 0 1px 0 line is a hairline that is painted as part of the cell, so it moves with it. background-color belongs on the cells too, because a transparent stuck cell lets the rows scroll visibly underneath it. min-inline-size: 40rem on the table keeps the columns from collapsing when the wrapper scrolls sideways, and overscroll-behavior-x: contain stops that sideways drag from carrying the whole page with it.

Two edges of the behaviour are worth checking against your own layout. tabindex="0" on the wrapper is there because a horizontally overflowing region is otherwise reachable only with a pointer; the role="region" plus aria-labelledby reference makes that focus stop announce what it contains, and the reference target may sit inside the scroll box that scrolls it out of view, since the accessible name comes from the DOM and not from what is currently painted. Sticky is also bounded by its containing block, so the header is only pinned while the table occupies the box: once the final row scrolls past, the header leaves with the table rather than holding at the top of the page the way a fixed element would. Nothing here helps a screen-reader user follow a row across columns; that association is carried by scope="col" on the header cells, and sticky only solves the sighted case.

Parameters

Named inputs for the code above
NameTypeRequiredDefaultDescription
max-block-sizelength on the scroll wrapperYes24rem in the code aboveThe definite block size that turns the wrapper into a scroll box; without it the wrapper grows to fit the table and nothing ever pins.
inset-block-startlength on the sticky cellsYes0The offset from the top edge of the scrollport where the header stops travelling.

Expected output

While the wrapper is in view, the header row stays flush against its top edge and body rows pass underneath it; the hairline under the header moves with it, and the horizontal scrollbar still reaches every column.

Usage notes

  • Sticky cells are positioned cells, so z-index applies: give the header 1 if you also make a first column sticky horizontally, or a later cell will paint over it.
  • Give the wrapper tabindex="0" and role="region" only when it is the sole way to reach the overflowing content; a wrapper that never overflows just adds a stop to the tab order.

Common mistakes

  • Applying position: sticky to the thead only, then concluding the property is unreliable when one engine ignores the row group.
  • Leaving the cells transparent so rows show through the header, then adding a background to the table instead of the cells.
  • Keeping border-collapse: collapse and losing the header's bottom border as soon as it pins.

Caveats

  • A sticky header only stays pinned while the table itself is in view; once the last row has passed, the header leaves with it.
  • overflow: hidden or overflow: auto on any ancestor between the table and the intended scrollport silently reassigns the pin to that ancestor.
  • The caption is inside the scroll box here, so it scrolls out of view; move it outside the wrapper and keep the aria-labelledby reference if it must stay readable.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References