position: sticky and the parent that eats it
On the hour I lost to a sidebar, the three rules that aren't in the one-line explanation, and a snippet that finds the culprit before the kettle boils.
I spent an hour last week on a sidebar that would not stick.
The CSS was four lines and all four were correct. position: sticky, top: 2rem, in a browser that supports it, on an element with plenty of room to travel. It sat there. No error, no warning, nothing in devtools showing a rule struck through, because nothing was wrong with the rule. The rule was just being ignored by something three ancestors up that I hadn’t looked at.
This is going to happen to everyone who picks this property up, so here’s the short version and then the actual answer.
Half relative, half fixed
Sticky is a hybrid of relative and fixed, and it switches between them at a threshold you nominate.
Before the element’s box would scroll past that threshold, it behaves exactly like position: relative. It’s in the flow, it takes up space, everything around it lays out normally. Once the scroll position crosses the threshold, it stops moving and behaves like position: fixed, but only relative to its nearest scrolling ancestor, and only for as long as its own parent’s box is still on screen.
That last clause is doing most of the work, and it’s the bit that isn’t in the one-line explanation anyone gives you.
Three rules the one-liner leaves out
You have to give it a threshold. position: sticky on its own does absolutely nothing. Not “sticks to the top by default,” nothing at all. You need at least one of top, bottom, left or right with a value, and that value is the offset it sticks at. This is the single most common way it silently fails and it’s a one-word fix.
It only sticks inside its parent. The element cannot leave the box of its containing block. So a sticky sidebar inside a wrapper that’s only as tall as the sidebar has zero distance to travel, sticks for zero pixels, and looks exactly like it isn’t working. Same for a sticky heading in a section: it rides down the section and then leaves with it. That behavior is deliberate and it’s the good part, and I’ll come back to it, but on day one it reads as broken.
Any ancestor with an overflow value other than visible takes over. If a parent is overflow: hidden, auto or scroll, that parent becomes the scrolling container the sticky element is measured against. The window scrolling is now irrelevant to it. It’ll stick within that parent, which usually doesn’t scroll at all, which means it never sticks.
There's no error, no warning, and no struck-through rule in devtools, because nothing is wrong with the rule.
And the culprit is usually a clearfix
Here’s the bit I found funny in a bleak way once I got to the bottom of it.
overflow: hidden on a container is one of the oldest tricks in the box: it establishes a new block formatting context, so the parent contains its floated children instead of collapsing to zero height. Half the layouts I’ve ever built have that rule in them somewhere, usually with no comment, usually applied to something generic like .wrapper or .row, and I put it there myself and forgot about it within the day.
It’s also on anything that ever needed to crop an image, anything that once had a carousel in it, and every “stop this thing bleeding out on mobile” fix ever committed at 6 pm.
None of those authors, me included, had any idea they were declaring a scroll container. In 2014 that had no consequences worth noticing. In 2017 it quietly disables a layout feature three levels down, and there’s no connection between the symptom and the cause that you’d guess at.
Finding it in ten seconds
Rather than reading up the tree by hand in the styles panel, which is what I did for most of that hour, walk it in the console:
let el = document.querySelector('.sidebar');
while (el = el.parentElement) {
const o = getComputedStyle(el);
if (o.overflow !== 'visible' || o.overflowY !== 'visible') {
console.log(o.overflow, o.overflowY, el);
}
}
Every ancestor that could be eating it, in order, in one go. Mine was a .row seven levels up in a partial I hadn’t opened.
The fix is then a judgment call, because that rule is there for a reason even if nobody remembers it. If it’s a clearfix, swap it for the pseudo-element version and the sticky comes back. If it’s genuinely cropping something, move the crop down to the element that needs it rather than the whole row.
Worth the trouble
Now that I’ve stopped fighting the “only inside its parent” rule, that rule is the reason to use this at all.
Section headings that ride down their own section and hand over to the next one. That’s the effect everybody built with a scroll listener and a class toggle, and it was always slightly wrong at the boundary, and it’s now the default behavior of two declarations.
Table headers that stay put per table, in a long comparison table, without cloning the header row into an absolutely-positioned duplicate.
A sidebar of filters that follows you down a long results page and then stops when the results do, rather than hovering over the footer looking lost.
The thing they have in common is that they’re all cases where position: fixed was subtly wrong and everyone shipped it anyway, because fixing it properly meant measuring the parent’s height in JavaScript on scroll and on resize and hoping.
Support, as of this month
Chrome got it unprefixed in the winter. Firefox has had it for ages. Safari needs -webkit-sticky as the value, which is an unusual place for a prefix to live and catches people out because it goes on the value and not the property.
Edge does not have it yet, and IE never will.
.section__heading {
position: -webkit-sticky; /* Safari, and note it's the value */
position: sticky;
top: 0;
}
Which is fine, because this degrades better than almost anything else I use. A browser that doesn’t understand it gets a heading that scrolls normally, and a heading that scrolls normally is a heading. There’s nothing to polyfill and nothing to apologize for. I’d put it straight into client work on that basis and I have.
Four lines, and an hour
And the hour was entirely a rule I wrote three years ago for a completely different reason.
I’ve started leaving a comment on overflow: hidden now, saying what it’s containing, which feels like a small and slightly pathetic act of maintenance until you’re the person seven levels down wondering why nothing sticks.