On This Page
Everyone's first move is position: relative on the parent. It does nothing. Sticky doesn't care whether the parent is positioned, and I've watched people add it, z-index and a transform before they look at the one property that's actually in the way.
The usual culprit is an overflow value on an ancestor. The less usual ones are a flex or grid alignment you forgot you set, or a missing top. There are five causes in total, and this page covers all of them, so you can stay here.
The sidebar that scrolls away with everything else
Here's what you see. A table of contents (or a header, or a filter bar) has position: sticky; top: 1rem. You scroll. It scrolls away with the page like it was never sticky at all. Nothing in the console. No warning. The declaration isn't struck through in the Styles pane either, which is what makes this one annoying.
The tell is in the Computed pane. With "Show all" ticked, position reads sticky and top reads 16px. Everything is correct, and it still doesn't work. When the properties are right and the behaviour is wrong, the problem is never on the element. It's somewhere above it.
I hit this on a docs layout: a two-column page, article on the left, a TOC on the right that had worked for months. Someone added a wrapper with overflow-x: hidden to stop a stray code block from causing horizontal scroll on phones. The TOC went dead on desktop and nobody noticed for a week, because nobody scrolls a docs page far enough on a laptop to see the difference. A QA person on a big monitor caught it.
One HTML file, no build step
Save this and open it in any browser. It's the working version, with a top value and a tall column beside the sidebar.
html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.5 system-ui; }
header { height: 60px; background: #222; color: #fff; }
.layout { display: flex; gap: 2rem; }
main { flex: 1; height: 2400px; background: linear-gradient(#eee, #ccc); }
aside { width: 220px; }
.toc { position: sticky; top: 1rem; background: #ffd; padding: 1rem; }
</style>
</head>
<body>
<header>Header</header>
<div class="layout">
<main>Long article</main>
<aside><div class="toc" id="toc">Table of contents</div></aside>
</div>
<div style="height: 600px"></div>
</body>
</html>Scroll down and the yellow box parks 16px from the top. Now break it, one edit at a time. I ran each of these in Chromium at 1200 by 800 after scrolling 500px, and read getBoundingClientRect().top on the TOC. Working means 16. Broken means a big negative number, because the box scrolled off.
| Edit | top after scrolling | Verdict |
|---|---|---|
| none | 16 | works |
delete top: 1rem | -440 | broken |
.layout { align-items: flex-start } | -440 | broken |
aside { align-self: flex-start } | -440 | broken |
.layout { overflow: hidden } | -424 | broken |
.layout { overflow-x: hidden } | -424 | broken |
.layout { overflow: auto } | -424 | broken |
.layout { overflow: clip } | 16 | works |
.layout { overflow-x: clip } | 16 | works |
(Anything far below 16 means the box scrolled off. I didn't dig into why the wrapper cases land at -424 instead of -440.)
Look at row six again. overflow-x: hidden alone kills it, even though you never touched the vertical axis. Hold that thought, it's the center of the whole thing.
Does your engine matter?
Barely. The five causes below are spec behaviour, not engine bugs. The versions that matter:
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
position: sticky (unprefixed) | 56 | 32 | 13 |
| sticky on table elements | 56 | 59 | 8 |
overflow: clip | 90 | 81 | 16 |
Those come from the MDN browser-compat-data JSON, which I pulled today. -webkit-sticky is the old Safari spelling from before 13. It's dead. If you still see it in a stylesheet, it's a copy-paste fossil and can go.
overflow: clip has shipped in all three engines since Safari 16 in 2022, so you'd expect it to be Widely available by now. I derived that from the dates and didn't read it off a status page, because webstatus.dev wouldn't render for me. Check it yourself before you put it in a support policy.
My honest limit: I only ran the repro in Chromium. The Firefox and Safari behaviour here follows the spec and the compat data. I haven't watched it happen on an iPhone for this particular layout, so if you see a difference there, trust your device.
For old browsers you don't need @supports. Write the fallback first and let the cascade sort it out:
css
.layout {
overflow: hidden; /* browsers without clip keep this */
overflow: clip; /* everyone else drops the line above */
}A browser that doesn't know clip throws the second declaration away at parse time and keeps the first. You lose sticky there, but you don't lose the layout.
What actually went wrong: sticky has a lane, and yours is zero pixels tall
A sticky element isn't pinned to the screen. It moves inside a rectangle, and it can't leave that rectangle. When the rectangle has no room, or the thing it's measured against isn't what you think it is, sticky quietly behaves like relative.
Two boxes decide where it can go. The first is the sticky view rectangle: the nearest ancestor that's a scroll container. The second is its containing block, which for sticky is the parent box. Your element can only slide inside the parent, never out of it.
Which gives you five ways to end up stuck:
- No threshold.
position: stickywith notop,bottom,leftorrighthas nothing to stick to. MDN's wording: if both insets on an axis areauto, it behaves asrelativeon that axis. - A clipping ancestor. Any ancestor with
overflowset tohidden,scroll,auto(oroverlay) becomes the scroll container sticky measures against, even if it never scrolls. Your element sticks to that, and that box is as tall as its content, so there's nothing to scroll against. - The parent is too short. The parent is the lane. If the parent is only as tall as the sticky element, the lane has no length.
- The sticky element is stretched. Make the sticky box itself a flex or grid item with default
align-self: stretchand it grows to fill the row. It's already as tall as its lane, so it can't move. htmlandbodyboth setoverflow. A special case of cause 2, and the one that bites at page level.
How the browser decides how far sticky can move
Think of an overhead projector with a transparency sheet taped inside a frame. The sticky element is a sticker on the sheet. It can slide, but only inside the frame, and only as far as the frame lets it. Clip the frame to your content height and the sticker has nowhere to go.
Now the mechanism. Roughly, on every scroll (this is my simplified model, the spec has more edge cases):
- Find the sticky element's nearest scroll container. Per MDN, that's the nearest ancestor with
overflowofhidden,scroll,autooroverlay, "even if that ancestor isn't the nearest actually scrolling ancestor". If there isn't one, it's the viewport. - Compute the sticky view rectangle: that scroller's padding box, inset by your
top/bottomvalues. - Find the containing block, the parent box.
- Shift the element just far enough to stay inside the view rectangle, but never past the edge of the containing block.
Step one is the trap. The browser doesn't ask "does this ancestor scroll?" It asks "does this ancestor could scroll, according to its overflow value?" A div with overflow: hidden that's exactly as tall as its content is a scroll container with nothing to scroll. Sticky binds to it, and since it never moves relative to its own content, the sticky element never moves either.
That also explains overflow-x: hidden killing sticky on the vertical axis. The spec says that if one axis is visible and the other isn't visible or clip, the visible one computes to auto. Here's the proof from the page above, from getComputedStyle:
DIV.layout ox=hidden oy=autoYou wrote one axis. The browser filled in the other. Now the wrapper is a scroll container on both axes, and your sticky element is bound to it.
overflow: clip is the way out because it clips painting without creating a scroll container. And clip on one axis plus visible on the other stays visible. My overflow-x: clip row confirms it: the computed output was ox=clip oy=visible.
Cause 4 is just flexbox being helpful. A flex item defaults to align-self: stretch, so an aside that's a flex child gets the full row height. A sticky aside that's 2400px tall inside a 2400px parent has zero travel. Put align-self: flex-start on it and it shrinks to content height, leaving the rest of the parent as room.
Two theories I burned an afternoon on, then the ancestor walk
Dead end one: specificity. The TOC wasn't sticking, so some other rule must have been overriding position. I spent about forty minutes in the Styles pane hunting for a rival rule. There wasn't one. The Computed pane said position: sticky the whole time, and that was the moment I should have stopped. If the computed value is sticky, the cascade did its job. The cascade is not the problem.
Dead end two: the parent needs position: relative. This is the folk remedy, and it's wrong. I added it to .layout, reloaded, saw no change, and added z-index too out of desperation. Sticky creates its own stacking context and uses the parent as its containing block whether or not the parent is positioned. A two-second check that kills this theory: remove the position: relative and see that a working page keeps working.
Neither theory was dumb. Both were plausible. And both were about the element, when the answer was above it.
What worked. Run the checks in this order, because it goes cheapest to most annoying:
- Computed pane, Show all. Is
positionsticky? Is there atop,bottom,leftorrightthat isn'tauto? If no, it's cause 1. Done. - Select the element, then the console. Walk the ancestors for anything that isn't
overflow: visible:
js
// Select the sticky element in Elements first, so $0 points at it.
let el = $0, hits = [];
while ((el = el.parentElement)) {
const s = getComputedStyle(el);
if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
hits.push({
el,
overflowX: s.overflowX,
overflowY: s.overflowY,
height: el.getBoundingClientRect().height,
scrollHeight: el.scrollHeight,
});
}
}
console.table(hits);I tested this on every broken page above. On the working page it prints an empty table. On overflow-x: hidden it prints ox=hidden oy=auto for the wrapper. Any row in that table is a scroll container sticky is binding to. Note html and body are in there too: if only body has overflow-x: hidden, the browser promotes that value to the viewport and sticky still works. If both do, body stays a scroll container, and sticky dies. I measured it: with overflow-x: hidden on just body, my sticky bar stayed at 0 after scrolling 500px. With it on both html and body, the bar landed at -420. That's cause 5.
- If the table is empty and it still fails, compare heights. Click the sticky element's parent and read its box in the Layout pane or via
getBoundingClientRect(). If the parent is barely taller than the sticky element, that's cause 3. If the sticky element itself is a flex or grid item, check its height against its parent's. Same height means cause 4.
The tell, the one observation that sorts everything: Computed values are correct, and the console table has a row. If the table has a row, it's cause 2 or 5. If the table's empty, it's 3 or 4.
Ranked by how often I've actually seen each one in real codebases:
- A clipping ancestor (cause 2). By a wide margin, and usually added for something unrelated.
- A short parent or alignment that shrinks the lane (causes 3 and 4 together).
- No threshold (cause 1). Fast to spot, easy to forget when converting from
fixed. htmlplusbody(cause 5). Rare, and it takes a while to find the first time.
The one that bit me was cause 2, from a mobile-horizontal-scroll fix. That's the usual story.
The fix, per cause
Cause 1 is a one-liner:
diff
.toc {
position: sticky;
+ top: 1rem;
}Cause 2 has three options, best first:
diff
.layout {
- overflow-x: hidden;
+ overflow-x: clip;
}clip hides the overflow without becoming a scroll container. That's the one I'd ship. It changes nothing else you were relying on, except that you can no longer scroll the box programmatically, which nobody wanted from a layout wrapper anyway.
If you can't use clip (very old Safari), find what's actually overflowing and fix that instead. Usually it's a pre block or a 100vw element. A hidden-overflow wrapper is a patch over a width bug somewhere else. I don't love the patch, because it fixes the symptom in the wrapper while the real problem, a child wider than its column, is still there and will bite the next person.
The third option is to move the sticky element so it isn't inside the clipped box. Sometimes that's the right call, but it means restructuring the markup.
Cause 3 and 4 share a fix. Don't let the sticky box be the short or stretched one, put the stickiness on the item whose parent is tall:
diff
.layout { display: flex; align-items: flex-start; }
-aside { width: 220px; }
-.toc { position: sticky; top: 1rem; }
+aside { width: 220px; position: sticky; top: 1rem; }Wait, that looks different from before, so here's the logic. With align-items: flex-start, the aside shrinks to content height. Its containing block is .layout, which is 2400px tall because of main. So aside has the whole column to slide in, and I verified it works (top reads 16 after scrolling). If you'd rather keep the sticky box nested, leave aside stretched and put sticky on .toc as in the original file.
For a sticky element that's directly a flex item and stretching, add align-self: flex-start to it. Also verified: 16.
Cause 5 is the same as cause 2: overflow-x: clip on body instead of hidden. Test showed body with overflow-x: clip and html with overflow-x: clip both keep the bar at 0.
What not to do. position: relative on the parent does nothing for sticky. z-index: 9999 doesn't help a sticky box that isn't moving. And -webkit-sticky hasn't been needed since Safari 13.
Writing it so it can't break
Rules I'd put in a design system:
- Never use
overflow: hiddenon layout wrappers. Useoverflow: clip, or better, don't clip at all and fix the child that's too wide.overflow: hiddenshould mean "this is deliberately a scroller or a clipped widget". - Put the sticky logic in one place. A
.sticky-toputility withposition: sticky; top: var(--sticky-offset, 0)means nobody ships a sticky element without a threshold. - Set
align-items: starton two-column layouts that contain a sticky sidebar. A sidebar that stretches is a sidebar that can't stick. - Use
scroll-padding-topand the same offset variable so anchor jumps don't land under the sticky header.
css
:root { --sticky-offset: 1rem; }
.sticky-top {
position: sticky;
top: var(--sticky-offset);
}
.two-col {
display: flex;
align-items: flex-start; /* lane stays as tall as the row */
gap: 2rem;
}
.page-wrap {
overflow-x: clip; /* hides the stray overflow, doesn't make a scroller */
}This renders as written in current Chrome, Firefox and Safari. On Safari 15 and older, overflow-x: clip is ignored and the page-level case degrades to "no clipping", which is a better failure than a dead sticky bar.
Making a one-engine break fail CI
A dead sticky element has no error, so a test has to scroll and measure. This Playwright test fails the build in any engine where the element drifts:
ts
import { test, expect } from '@playwright/test';
test('TOC stays pinned', async ({ page }) => {
await page.goto('/docs/some-long-page');
await page.evaluate(() => window.scrollTo(0, 600));
const top = await page.locator('#toc').evaluate(
(el) => Math.round(el.getBoundingClientRect().top)
);
expect(top).toBeLessThan(40); // pinned near the threshold, not scrolled away
});Run it under chromium, firefox and webkit projects in playwright.config.ts, so a one-browser break shows up before QA does. Add one more rule to your review checklist: any PR that adds overflow to a wrapper gets one question, "does anything inside it use sticky?". That single question would have saved me a week of production time.
Worth pinning
- Correct Computed values with wrong behaviour means the cause is above the element.
overflow-x: hiddenturns the other axis intoauto. You made a scroll container without asking for one.overflow: clipis the replacement foroverflow: hiddenwhen you only want the clipping.- Sticky moves inside its parent. A short or stretched parent means a lane with no length.
- When it breaks, run the ancestor walk first. It takes ten seconds.
Neighbouring topic: the same ancestor walk, with different properties, finds the transformed parent that breaks position: fixed. Same trick, different culprit.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.