notes 4 min layout
Why your sticky header stops sticking
position: sticky has no error state. When it does not work it simply behaves as relative - and the cause is usually an ancestor you have never looked at, changed by somebody solving a different problem.
Two things have to be true before sticky does anything at all, and both are easy to satisfy by accident and easy to break by accident.
-
A threshold. At least one of
top,right,bottomorleftmust be set. Without one there is no line to stick to, and the element scrolls away exactly as if nothing were set. - A scrollport that actually scrolls. Sticky positions the element inside the nearest scrolling ancestor - and once an ancestor establishes one, that box is the whole world the element can travel in.
Everything below is a variation on the second one.
The ancestor properties that end it #
- overflow: hidden
- The commonest by a distance, and usually written as
overflow-x: hiddenonbodyto hide a sideways scroll. It establishes a scrollport with no scrolling in it, so the sticky element sticks inside a box that never moves. The two bugs are the same bug. - overflow: auto or scroll
- Same mechanism, stated deliberately. The element now sticks within that container rather than within the page - correct behaviour, and rarely the intended one.
- overflow: clip
- The exception worth knowing.
clipcuts the overflow without establishing a scrollport, so sticky survives it. It is the right property for the full-bleed case that produced thehiddenin the first place. - A parent with no room
- Sticky travels inside its own parent, never past it. A header whose parent is only as tall as the header has nowhere to go, and this is what a flex or grid item usually is by default.
- contain: paint
- Establishes a containing block for the same reason
overflowdoes. Added for performance, and it takes sticky with it.
Scroll this column.
The header stays.
Nothing above it clips.
This is what sticky is for.
Keep going.
Still there.
Scroll this column too.
The header leaves.
Nothing in the CSS on it changed.
A wrapper has overflow: hidden.
Keep going.
Gone.
Finding the ancestor #
The search is mechanical, which makes it worth doing in one line rather than by opening panels:
const blockers = [];
let node = document.querySelector('.your-sticky-thing');
while ((node = node.parentElement)) {
const s = getComputedStyle(node);
const overflow = `${s.overflowX} ${s.overflowY}`;
if (/hidden|auto|scroll/.test(overflow) || s.contain.includes('paint')) {
blockers.push([node, overflow, s.contain]);
}
}
blockers;
// The first entry is the one. Everything after it is above the
// scrollport and cannot have caused anything.
The first result, not the last. Once one ancestor establishes a scrollport, the ancestors above it are outside the element's world entirely - and a list read bottom-up sends people to change overflow on html, which is how a one-element bug turns into a page-wide one.
The fix that is almost always right #
/* Was: */
body { overflow-x: hidden; }
/* Becomes, on the element that actually overflows: */
.hero-decoration {
overflow-x: clip;
}
clip does what the author meant - hide what sticks out - without the side effect they did not ask for. And scoping it to the offending element rather than the document means the next sticky thing anybody adds is not born broken.
If the cause is a parent with no height rather than an overflow, the fix is on the parent: give it the height the sticky element is meant to travel through, usually by removing an align-items: stretch assumption or setting height: 100% on the intermediate wrapper.
What sticky is not #
-
Not
fixed. A fixed element leaves the flow and is positioned against the viewport; a sticky one keeps its space and its place in the layout, which is why it does not cause a jump when it engages. - Not a scroll listener. It is laid out by the engine, off the main thread, and it cannot be janky. Anything replacing it with a scroll handler and a class toggle is slower and worse in every browser.
- Not free of stacking. A sticky element creates a stacking context, so its children sort among themselves. A dropdown inside a sticky header cannot paint above something outside it, whatever number it carries.
That last one is the same trap as z-index: 9999, arriving from a different direction. Sticky is on the list of properties that open a stacking context, and almost nobody knows it is.
Written by Ján Turský
Building LoupeKit and other browser tools out of Bratislava, under Apptiary.
tools in this note