On This Page
.toc {
position: sticky;
top: 0; /* looks right */
}Computed → position: sticky ✔
Computed → top: 0px ✔
Scroll the page 500px → the element scrolls away like it's position: staticIf that's your DevTools right now, you've already done the obvious checks, and every top answer you've found has told you to add top: 0. You did. Skip down to the ancestor walk. If you want to know why it fails, keep reading.
What renders instead of what you wrote
The element behaves as if position: sticky was never there. It scrolls with the page, or it sticks for half a second and then leaves, or it sticks in Chrome and not in the other browser the tester used. Nothing in the console. Nothing struck through in the Styles pane. The declaration is valid and it's being applied.
That last part is what makes this bug eat an afternoon. The cascade did its job. Computed says sticky. The failure is downstream of the property, in the geometry sticky needs before it can do anything.
Four things have to be true for sticky to work, and the fourth is the one that bites real codebases:
- The element has an inset (
top,bottom,inset-block-start, and so on) that isn'tautoon the axis you want. - Its containing block (basically its parent) has room for it to travel.
- It isn't stretched to fill that room.
- No ancestor between it and the scrolling you care about is a scroll container.
I hit number 4 on a docs layout at a 1280px viewport. A table of contents in a left rail was supposed to follow you down the page. It didn't. Someone had added overflow-x: hidden to the page wrapper months earlier to kill a horizontal scrollbar on phones, and nobody remembered.
One HTML file, no build step
Save this and open it. Scroll down. The yellow box scrolls away.
<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
.page-wrap { overflow-x: hidden; } /* the culprit */
.layout { display: flex; gap: 1rem; }
.rail { width: 200px; background: #eee; }
.toc { position: sticky; top: 0; height: 60px; background: gold; }
.content { flex: 1; height: 2000px; background: #ddf; }
</style>
<div class="page-wrap">
<div class="layout">
<aside class="rail"><div class="toc">TOC</div></aside>
<main class="content">Long content</main>
</div>
</div>Now change .page-wrap { overflow-x: hidden; } to overflow-x: clip and reload. The box sticks. One word.
I ran this in headless Chromium 141 and measured getBoundingClientRect().top of the box after scrollTo(0, 500):
no overflow on wrapper → 0 (sticks)
overflow: hidden → -500 (scrolled away)
overflow: clip → 0 (sticks)
overflow-x: clip only → 0 (sticks)Zero means it's pinned at the top of the viewport. Minus 500 means it went with the page.
Where sticky breaks, and where it doesn't
Sticky itself is old news in every engine you're likely to support. The -webkit-sticky prefix you'll find in copy-pasted snippets is for very old Safari, and you can drop it. The cause-4 behaviour (an ancestor scroll container wins) is how the spec works, so it's the same in Blink, Gecko and WebKit. There's no "works in Firefox" version of this bug.
What does differ is the fix, because the clean fix is overflow: clip:
| Browser | overflow: clip supported from |
|---|---|
| Chrome / Edge | 90 |
| Firefox | 81 (partial before that) |
| Safari desktop | 16.0 |
| Safari on iOS | 16.0 |
Those numbers come from caniuse. MDN lists overflow overall as Widely available since 2015, but the clip value is the newer part. If you still support Safari 15 or older, you need the fallback in the fix section. Feature-detect it with:
@supports (overflow: clip) {
.page-wrap { overflow-x: clip; }
}What changes next: the CSS Working Group has an open issue (csswg-drafts #865) about letting sticky work inside overflow: hidden|auto ancestors that aren't actually scrolling. As of the last comment I could find, from November 2025, it's a proposal, not a decision. Don't write code that depends on it.
So what does sticky actually need?
Sticky is relative positioning with a leash. The browser lays the element out normally, then, as you scroll, shifts it so it stays inside a rectangle. That rectangle is built from two things: the nearest ancestor that is a scroll container (that's what it sticks relative to), and its containing block (that's the boundary it can't leave).
Say it plainly:
- No inset, no leash. With
topandbottombothauto, sticky behaves asrelativeon that axis. MDN says the same. - The element can only move inside its parent. If the parent is exactly as tall as the sticky child, there's nowhere to go.
- The "scrolls against" ancestor is the nearest one with a scrolling mechanism, meaning
overflowofhidden,scrollorauto, even if it never scrolls. MDN calls this out and it's the whole of cause 4.
Why a clipping ancestor kills the threshold
Here's the mechanism, which took me longer than the fix.
overflow: hidden makes the box a scroll container. It has no scrollbar, and the user can't drag it, but el.scrollTop = 50 works on it. Because it's a scroll container, it becomes the thing sticky measures against. Your wrapper is as tall as its content, so its scrollport never moves. Sticky compares the element's position to a viewport that doesn't scroll, finds nothing to do, and sits there. The page scrolls, but sticky isn't listening to the page any more. It's listening to the wrapper.
overflow: clip clips the same way visually but doesn't create a scroll container. MDN says so directly: the box is not a scroll container and programmatic scrolling isn't possible. Sticky skips over it and finds the real scroller (the viewport). That's why one word fixes it.
Two gotchas hide in here.
overflow-x: auto alone is enough. You set it on a wrapper to make a wide table scroll sideways. Per the overflow computed-value rules, when one axis is anything but visible or clip, a visible on the other axis computes to auto. I checked: with overflow-x: auto the computed overflow-y came back auto. With overflow-x: clip it came back visible. So you never wrote anything about the vertical axis, and it broke anyway.
overflow: clip also changes layout. hidden creates a new block formatting context. clip doesn't. If you were using overflow: hidden as a clearfix or to stop margins collapsing, switching to clip will quietly bring those problems back. (More on that below.)
The wrong turns, then the ancestor walk
First theory: something overrides position. Plausible, because half the sticky threads on forums are "my theme CSS wins". I opened the Computed pane with "Show all" checked, clicked the little arrow next to position to jump to the winning rule, and it was mine. Computed said sticky. Dead end, ten minutes.
Second theory: missing position: relative on the parent. This is cargo cult from absolute positioning, where the parent matters. Sticky doesn't care. Adding it changed nothing, and the two-second check that killed it was removing it again and seeing no difference. Sticky isn't looking for a positioned ancestor. It's looking for a scroll container.
Third, and I lost the most time here: I assumed the page was the scroller. So I kept looking at my element's own rules and its parent's height. Everything about my element was right.
The tell: getBoundingClientRect().top on the element stayed correct relative to its parent but the parent was moving with the page. The sticky element wasn't failing to stick to the viewport. It was sticking, perfectly, to a wrapper that moves with the page. Once I thought "sticky to what", the question became "which ancestor", and that has a mechanical answer.
Here's the walk. Select the broken element in the Elements panel ($0 in the console), then paste this:
// List every ancestor that is a scroll container (these break sticky),
// and note whether the clipping is on x, y or both.
let el = $0, hits = [];
while ((el = el.parentElement)) {
const s = getComputedStyle(el);
const scrolls = v => ['hidden', 'scroll', 'auto', 'overlay'].includes(v);
if (scrolls(s.overflowX) || scrolls(s.overflowY)) {
hits.push({ el, overflowX: s.overflowX, overflowY: s.overflowY });
}
}
console.table(hits);Whatever lands in that table is a suspect. <html> and <body> sometimes show up with overflow-x: hidden, which is a common "fix" for horizontal scroll. Yes, that breaks sticky too, and it's the one people forget because it's in a reset file.
Firefox has a feature Chrome doesn't: the Rules panel greys out declarations that can't do anything and tells you why, via the info icon next to them. It's worth trying for top on a non-positioned element and for z-index on a static one. I haven't seen it flag the sticky-in-scroll-container case, so don't expect it to help here.
Then the ranked checklist, in the order I'd run it on your page:
- Console snippet above. Any ancestor listed? That's cause 4, and it's the one I'd bet on when the element's own rules all look right.
- Does the element have a non-
autoinset? Check Computed fortop. If it saysauto, that's cause 1. - Is the element the direct child of a flex or grid container? Turn on the flex/grid overlay badge in the Elements panel. If the sticky element's box spans the whole row, it's being stretched. Cause 3.
- Is the parent barely taller than the element? Hover the parent in the Elements panel to see its box. Short parent, short travel. Cause 2.
Four ways out, ranked
Cause 4 (ancestor scroll container). Best fix:
- .page-wrap { overflow-x: hidden; }
+ .page-wrap { overflow-x: clip; }Ship that if you're on Safari 16+ and Chrome 90+. If you need to support older Safari:
.page-wrap { overflow-x: hidden; } /* old browsers */
@supports (overflow: clip) {
.page-wrap { overflow-x: clip; } /* everything newer */
}Old Safari still gets the bug. If that's unacceptable, the real fix is to remove the wrapper's overflow and find what's causing horizontal overflow in the first place. (overflow-x: hidden on a wrapper is almost always hiding a 100vw element or a negative margin.)
If you need hidden for BFC purposes, use display: flow-root. That makes a BFC without a scroll container.
Cause 3 (stretched flex or grid child). I measured this one too. A sticky element that's a direct flex child with the default align-self: stretch filled the whole row, had nowhere to travel, and scrolled away (-500). Adding align-self: flex-start made it stick (0). In grid, same idea: align-self: start.
.rail {
position: sticky;
top: 0;
+ align-self: flex-start;
}Cause 2 (parent too short). The parent must be at least as tall as the distance you want the element to travel. In my test, a 100px parent with a 50px sticky child: scroll 500px and the child sat at -450, pinned to the bottom of its parent. That's correct behaviour, and also not what you wanted. Make the parent the full column.
Cause 1 (no inset). Add top: 0 or a sensible value. If you have a fixed header, top: var(--header-h).
The shortcuts that aren't fixes: position: fixed (leaves the flow, you'll reserve space by hand), JavaScript scroll listeners (jank, and you'll rewrite it for every layout), and !important, which does nothing here because the cascade isn't the problem.
The version I'd put in the design system
.sticky-rail {
position: sticky;
top: var(--sticky-top, 1rem);
align-self: start; /* works in flex and grid */
max-block-size: calc(100dvh - var(--sticky-top, 1rem) * 2);
overflow-y: auto; /* inside the sticky element, not its ancestor */
}
/* Layout wrappers: clip, don't hide */
.page-wrap { overflow-x: clip; }Two opinions. overflow: hidden on layout wrappers should be treated as a code smell, because it silently changes what sticky measures against. And a max-block-size on the sticky element is worth having, since a sticky sidebar taller than the viewport is unreachable at the bottom. Put the scrolling inside the sticky element, not on an ancestor.
One honest limit: I only tested this in Chromium 141. The spec behaviour is the same across engines, and the caniuse data covers overflow: clip, but I haven't reproduced the exact numbers in Safari or Firefox. Run the file above in both before you trust my table.
Does CI catch any of this?
Partly.
- Stylelint 16: there's no stock rule for "sticky inside a scroll container", because that depends on the DOM. You can ban
overflow: hiddenon wrapper selectors withdeclaration-property-value-disallowed-list, but I'd rather flag it in review than lint it. - Playwright with three projects (Chromium, Firefox, WebKit): a test that scrolls and asserts position is the one that works. Something like:
await page.evaluate(() => window.scrollTo(0, 500));
const top = await page.locator('.toc').evaluate(el => el.getBoundingClientRect().top);
expect(top).toBe(0);- A layout canary page in your design system with a sticky element inside each wrapper component. When someone adds
overflow-x: hiddento a shared wrapper, the canary fails. - Keep a
browserslistfile committed so "do we support Safari 15" has a written answer before somebody writesoverflow: clipin a PR.
Neighbouring topic, one line: this is the same family as position: fixed breaking inside a transform ancestor, where the containing block moves. Different mechanism, same shape: the property is fine and an ancestor changed what it's relative to.
Worth pinning
- Computed says
stickyand it still doesn't stick? Stop looking at the element. Walk the ancestors. overflow: hidden,autoandscrollmake a scroll container.overflow: clipdoesn't. That's the one-word fix, Safari 16 and up.- Setting
overflow-xalone can changeoverflow-ytoautothrough the computed-value rules. - Sticky only moves inside its parent, and a stretched flex or grid child has no room to move:
align-self: start. position: relativeon the parent is irrelevant for sticky. Don't add it.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.