How do you build a table whose header row stays visible while the table body scrolls, using position: sticky?
answer
- stick the cells, not just the group
- transparent cells give it away
- collapsed borders belong to the table
- shadows travel, borders may not
- the corner cell owns two axes
basics
~20 sApply position: sticky with top: 0 to the header cells and give them an opaque background, since rows scroll underneath. With border-collapse: collapse the borders scroll away, so use border-collapse: separate and draw the dividing line with box-shadow.
solid answer
~50 sPut `position: sticky; top: 0` on the header cells themselves and give them a solid `background`, because the body rows pass underneath and would otherwise show through. Two details bite. First, borders: with `border-collapse: collapse` the collapsed borders belong to the table rather than to the cell, so they scroll out of view while the cell stays — the reliable fix is `border-collapse: separate; border-spacing: 0` plus a `box-shadow` as the dividing rule. Second, scope: whatever container actually scrolls is what the header sticks inside, so if the table sits in a wrapper with `overflow: auto`, `top: 0` means the top of that wrapper, which is usually what you want. Sticking the row group rather than the cells works in current browsers but needs Chrome 91 or later, so cell-level sticky is the safest target.
code
css · 11 linestable {
border-collapse: separate;
border-spacing: 0;
}
thead th {
position: sticky;
top: 0;
background: Canvas;
box-shadow: inset 0 -1px 0 rgb(0 0 0 / 0.2);
}go deeper
Remember the two essentials: position: sticky; top: 0 on the header cells, and an opaque background so the scrolling rows do not show through them.
Explain the border behaviour — collapsed borders belong to the table, so they do not travel with a stuck cell — and why border-collapse: separate plus a box-shadow divider is the standard workaround.
Show you can build the full data-grid case: pick the scroll container deliberately, offset the inset for any toolbar already stuck above, and reason about which stuck cell paints on top when a frozen column and a sticky header intersect.
Weigh this against a virtualised grid component. Sticky cells are cheap and framework-free but tie header behaviour to table markup and to per-cell z-index bookkeeping; decide when the team should standardise on one grid rather than restyle tables case by case.
## The shape of the solution A scrolling table with a persistent header is one of the few genuinely fiddly sticky use cases, because tables have their own painting and border rules that interact with sticking. The core is short — sticky cells with an inset and a background — and the rest is dealing with those interactions. ```css .table-scroll { overflow: auto; /* this is the scroll container the header sticks inside */ max-block-size: 70vh; } .table-scroll table { border-collapse: separate; border-spacing: 0; } .table-scroll th { position: sticky; top: 0; background: Canvas; /* rows scroll underneath, so opacity matters */ box-shadow: inset 0 -1px 0 rgb(0 0 0 / 0.2); } ``` ## Why the background is mandatory A stuck cell is a positioned box painted over the in-flow content passing beneath it. Table cells have a transparent background by default, so without an explicit one you see body rows sliding through the header text. This is the first thing to check when a sticky header "looks glitchy" — it is not a rendering bug, it is transparency. ## Why the borders disappear This is the part that surprises people. With `border-collapse: collapse`, the collapsed borders are painted by the table, not by the individual cells. The sticky offset moves the cell box, but the table's borders stay where the table's geometry puts them, so the header's bottom rule scrolls away while the header itself stays pinned. The dependable fix is to stop collapsing: - `border-collapse: separate` with `border-spacing: 0` keeps the collapsed look while giving each cell its own borders, which move with the sticky cell. - Draw the divider with `box-shadow` — either an `inset` shadow as above, or an outer one — because a shadow is painted with the cell and needs no layout space. `outline` is another option that travels with the box, though it paints on all sides unless you accept that. ## Which element to stick Historically, only individual header and data cells reliably accepted `position: sticky`; browsers did not apply sticky to row groups or rows because of how table row groups are laid out. That changed — Chrome added sticky support for `thead` and `tr` in version 91 (2021), and current Firefox and Safari support it as well. Sticking the row group is more convenient for multi-row headers, since one rule covers every cell. If you need to support engines older than that, target the cells. It is the lower-risk choice and costs nothing: `thead th { position: sticky; top: 0; }` covers the whole header row because every cell independently holds the same threshold. ## The scroll container decides what `top: 0` means A sticky header sticks inside the nearest scrolling ancestor. If the table lives in a wrapper with `overflow: auto`, that wrapper is the container, and `top: 0` pins the header to the wrapper's top edge — normally what a data grid wants. If the table simply flows in the page, the header pins against the page scrollport instead, and if the page already has its own sticky toolbar you must offset for it: `top: var(--toolbar-height)` rather than `0`, so the two stuck elements stack rather than overlap. ## Two sticky axes at once A frozen first column is the same trick on the other axis: `position: sticky; left: 0` on the first cell of each row, again with a background. The interesting cell is the corner one, which belongs to both — it needs `top: 0` and `left: 0` together. When several stuck elements overlap, painting order matters. Stuck cells are positioned boxes, so among positioned boxes with no `z-index` the later one in document order paints on top; the corner cell must sit above both the header row and the frozen column, which means giving it a higher `z-index` than either. A simple, working assignment is a low value on the frozen column, a higher one on the header row, and the highest on the corner cell. ## What to check when it does not work The usual failures are the general sticky ones wearing a table costume: no inset declared, or an ancestor with `overflow: hidden` capturing the header so it sticks inside a box that never scrolls. Beyond those, table-specific checks are whether the cell has a background, whether the missing line is a collapsed border rather than a missing rule, and — on older engines — whether you stuck a row group that the browser will not stick.
- Why does the header's bottom border scroll away when border-collapse is collapse?Because collapsed borders are painted by the table rather than by the individual cells. Sticking offsets the cell box, but the table's border geometry is unchanged, so the line stays with the table and slides out of view. Switching to `border-collapse: separate` with `border-spacing: 0` gives each cell its own border, or you draw the divider with `box-shadow`, which is painted with the cell.
- How do you combine a sticky header row with a frozen first column?Two sticky axes. Header cells get `top: 0`; the first cell of each row gets `left: 0`; both need opaque backgrounds. The corner cell gets both insets and must paint above the other two, so give it the highest `z-index` of the three — otherwise the header row or the frozen column will overlap it as they slide past.
- The page already has a sticky toolbar 56px tall. What changes for the table header?The inset, not the approach. Set `top: 56px` — ideally `top: var(--toolbar-height)` — so the header pins just below the toolbar instead of underneath it. Both are sticking inside the same scrollport, so their thresholds have to be stacked deliberately; leaving the header at `top: 0` puts the two stuck elements in the same band and one will cover the other.
saying these in an interview costs you the question
- Leaves header cells transparent so rows show through
- Blames the browser when collapsed borders scroll away
- Assumes sticky never works on a table row group
- Forgets the wrapper's overflow defines what top: 0 means
- Gives every stuck cell the same z-index and expects correct overlap