Sticky table headers need two things and we shipped one
•6 min read
A table header declared sticky sat still while the rows scrolled past it, with no error anywhere. Two separate mistakes: a wrapper that could not overflow, and the sticky rule on the wrong element. Here is what each one does and how to tell them apart.
A sticky table header needs two things: an ancestor that genuinely scrolls, and position: sticky on the element that actually holds the position. Our data table shipped with the sticky rule on the thead and a wrapper carrying overflow-x: auto and no height at all, so neither half was true. The header scrolled away with the rows, nothing threw, nothing was logged, and devtools reported a scroll container that was never going to scroll. Both halves had to be fixed before a single column heading stayed on screen.
Why does overflow: auto not make a scroll container on its own?
A wrapper with overflow-x: auto and no height constraint is not a scroll container on the vertical axis, even though the computed styles say it is. CSS Overflow Module Level 3 specifies that when one axis is given a scrollable value, a specified value of visible on the other axis computes to auto. So overflow-y reads back as auto in devtools while the box itself still grows to exactly the height of its content. A box that is the height of its content never overflows, never scrolls, and gives a sticky child nothing to stick against.
However, if the other axis specifies a scrollable value, a specified value of visible computes to auto, enabling scrolling in its axis.
The practical version of that rule: auto is permission to scroll, not a reason to. Something has to constrain the height before there is any overflow for the permission to apply. A max-height is enough, and it is usually the better choice than a fixed height, because a short table should not leave an empty box under its last row.
Why did position: sticky on the thead do nothing?
The position property is not inherited, so declaring position: sticky on a thead leaves every th inside it computing position: static. Sticky positioning applies to the element that carries the declaration, and the element whose box has to stay on screen while rows slide underneath is the header cell. Moving position: sticky and the top offset onto each th is what makes the cells themselves the positioned elements. This is the same shape of mistake as a declaration placed one level away from the thing it was meant to affect, which is easy to write and slow to see.
Two other details ride along with the move. A sticky element needs at least one inset offset — top, right, bottom or left — because with none of them it resolves to its static position and behaves like a relatively positioned box that never leaves. And header cells that overlap body cells have to win that overlap, which is a z-index on the sticky th rather than anything the table arranges by itself.
The link measured 297 by 44 pixels and reported a pass. Tapping its centre hit the paragraph behind it. An inline box that wraps is not one rectangle, and the pseudo-element meant to enlarge it was positioned against the same broken geometry.
Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
No card to start. Cancel from the settings screen, not from an email.
Why do the rows read through a sticky header?
A sticky header with no background of its own is transparent, and the rows scrolling beneath it show straight through the text. A background declared on the thead does not solve this, because the cells are what sit above the scrolling content, and a background painted on the row group does not cover them. Set the background colour on each th, using the same surface colour the table sits on, so the header reads as a solid band rather than as a place where two sets of numbers overlap.
How do you tell the two failures apart?
Six symptoms, and which of the two halves each one points at.
Symptom
Cause
Fix
The header scrolls away with the rows
No ancestor between the header and the viewport actually scrolls
Constrain the container's height so it can overflow
Devtools reports overflow-y: auto and nothing scrolls
The other axis made this one compute to auto; the box is still content height
Add a max-height; auto is permission, not a constraint
Sticky is declared and the computed position is static
position is not inherited, and the rule was on the thead
Move position: sticky and the offset onto each th
The header holds still but never moves off its start position
No inset offset, so sticky resolves to the static position
Set top: 0, or whichever edge the header should hold
The header holds still and the rows read through it
The th has no background of its own
Paint the surface colour on each th, not on the thead
The header holds still and the first row draws over it
The header cells lose the overlap against the body cells
Raise the sticky th above the body cells with z-index
What the fixed component sets
The repaired table gives its scroll container a maximum height, sets overflow to auto on both axes, and puts position: sticky with a top offset, a z-index and the surface background on every th. The maximum height is a caller-overridable default rather than a fixed number, because a table of four rows and a table of four hundred want different boxes and neither should have to be told.
min(70vh, 34rem)Default maximum height of the table's scroll container, overridable per table.
One more scrolling decision belongs with these. The container contains horizontal overscroll but deliberately does not contain vertical overscroll: a sideways swipe should not drag the whole page with it, while a downward scroll that reaches the last row has to chain to the page. Containing both axes traps a phone reader inside the table the moment they start a scroll there, which is a worse bug than the one it prevents.
Why does position: sticky do nothing on my element?
Three causes account for most of it. The element has no inset offset, so sticky resolves to its static position and never moves. The nearest scrolling ancestor does not actually scroll, usually because its height is unconstrained. Or the rule is on a parent rather than on the element itself, and position is not an inherited property, so the child computes static. Check the computed position of the exact element you expect to stick before changing anything else.
Should position: sticky go on the thead or on the th?
On the th. The position property is not inherited, so a rule on the thead leaves every header cell computing position: static and nothing sticks. Putting position: sticky and top: 0 on each th makes the individual header cells the positioned elements, which is what has to hold its place while the body rows scroll underneath. The background and the z-index belong on the same cells for the same reason.
Does the scroll container need a fixed height?
No, a maximum height is enough and usually better. What sticky needs is a box that can overflow, and a max-height gives it one without forcing a short table to leave empty space beneath its last row. A viewport-relative maximum, clamped by an absolute value, keeps the table usable on a laptop and on a phone without a media query.
Why do table rows show through a sticky header?
Because the header cells are transparent. A sticky element is painted where it is, and whatever scrolls under it stays visible unless the element has a background of its own. Declaring a background on the thead does not cover the cells that overlap the scrolling content. Set the background on each th, matching the surface the table sits on, and the header becomes a solid band.
A screen-reader-only caption kept its padding after being hidden, and the invisible node measured 32 by 16 pixels instead of 1 by 1. The cause was not the hiding technique. It was two selectors of equal weight and the order they were imported in.
A div with overflow auto and no tab stop is content a keyboard user cannot reach at all. Making it focusable is half the fix; an unnamed focusable region is announced as a group with no label. What to add, in what order, and why a rewrite is where this gets lost.