Keep a table header visible with sticky positioning
Canonical URL: https://devexamples.com/css/apply-sticky-positioning-to-a-table-header/
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.
<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>.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
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
max-block-size | length on the scroll wrapper | Yes | 24rem in the code above | The 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-start | length on the sticky cells | Yes | 0 | The 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.
Related
Reveal an element the first time it enters the viewportBoth solve for what is currently inside the viewport rather than reacting to scroll events.
Related
Build a card grid that reflows without media queriesHeader pinning and card reflow share the same scroll container behaviour.
Same task or topic
Centre an element with flexbox and no wrapper markupSolves the Build a responsive layout task
References
- position - CSS | MDN(opens in a new tab) — MDN Web Docs. Documents sticky offsets and the scrollport that they are resolved against.
- CSS Positioned Layout Module Level 3(opens in a new tab) — W3C. Normative definition of sticky positioning and its containing-block bounds.
- The th element - HTML Living Standard(opens in a new tab) — WHATWG. Defines scope attributes that keep column association for assistive technology.
Source page: https://devexamples.com/css/apply-sticky-positioning-to-a-table-header/