On This Page
css
.toolbar {
position: sticky; /* Computed pane says: sticky. */
top: 0; /* Computed pane says: 0px. */
}scrollY = 0 → toolbar.getBoundingClientRect().top = 120
scrollY = 300 → toolbar.getBoundingClientRect().top = -180That's it. The declaration is there, DevTools agrees it's applied, and the bar scrolls off the top like it's static. If that's your screen right now, jump to the ancestor walk further down. If you'd like to know why the property you wrote isn't the problem, keep reading.
The bar that scrolls away like nothing happened
Here's the symptom, exactly. A header, a table <th>, a sidebar or a filter bar has position: sticky and a top value. You scroll. It doesn't stick. No console message, no warning, no struck-through declaration. The Styles pane is clean.
The autocomplete completions for position sticky not working tell you where people actually get hurt: ...with flex, ...with grid, ...with overflow hidden, ...with overflow scroll, ...tailwind, ...in safari, ...on mobile, ...inside div. I pulled those from Google's suggest endpoint on 4 October 2026. Notice what's not in the list: nobody types "...without top". The top-voted answer on every sticky thread says "add top: 0", and it's right about one cause in six.
I should say that I couldn't load the Stack Overflow hot feed on this run (the fetch was blocked), so the demand signals here are the autocomplete list, the long-running CSS WG thread csswg-drafts#865 ("support position:sticky inside an overflow:hidden|auto on general parents"), and the pile of "sticky not working" posts that every search engine serves for this string. The thread has been open and reopened for years. That tells you how many people hit the ancestor cause.
My own version of this: a dashboard shell in a design system, where somebody had added overflow-x: hidden to the layout wrapper to hide a horizontal scrollbar caused by a 1px rounding overflow. Every sticky table header inside it died at once. Nobody connected the two for weeks because the commit that added the overflow was two sprints older than the first sticky header.
Everything below was tested in Chromium 141 (Playwright 1.56). I haven't re-run it in Firefox or WebKit, so where I say "other engines" it's spec behaviour or a cited source, not something I watched happen.
One HTML file, five ways to break it
Save this, open it, scroll. The first bar sticks. Then break it with one of the three marked cases at a time.
html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sticky repro</title>
<style>
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
.bar { position: sticky; top: 0; background: #ffd; padding: .75rem; }
.filler { height: 2000px; margin: 0; background: linear-gradient(#eee, #ccc); }
/* Break it. Enable exactly one at a time. */
.cause-2 { overflow: hidden; } /* ancestor scroll container */
.cause-3 { display: flex; } /* stretched flex item */
.cause-3 aside { width: 8rem; } /* aside has no height of its own */
.cause-4 { height: 80px; } /* parent shorter than the bar's travel */
</style>
<!-- Works -->
<section>
<div class="bar">I stick</div>
<p class="filler"></p>
</section>
<!-- Cause 2: wrap the section above in <div class="cause-2"> ... </div> -->
<!-- Cause 4: add class="cause-4" to a wrapper that holds only the bar -->
<!-- Cause 3 -->
<div class="cause-3">
<aside class="bar">Sidebar</aside>
<main class="filler" style="flex: 1"></main>
</div>I ran about twenty variants like this and read getBoundingClientRect().top after scrollTo(0, 300). A value of 0 means it stuck. -300 means it scrolled away. The relevant results:
| Variant | top after 300px scroll |
|---|---|
| baseline | 0 (sticks) |
top: auto | -300 |
ancestor overflow: hidden | -300 |
ancestor overflow-x: auto (y left alone) | -300 |
ancestor overflow: clip | 0 |
ancestor overflow-x: clip | 0 |
parent height: 80px | -260 |
| sticky flex item, stretched | -300 |
same item, align-self: flex-start | 0 |
| sticky grid item, stretched | -300 |
same item, align-self: start | 0 |
overflow-x: hidden on <body> only | 0 |
overflow-x: hidden on <html> and <body> | -300 |
<th> in a border-collapse: collapse table | 0 |
Two rows in there are the ones I'd have bet wrong. Hold that thought until the cause list.
Does the browser matter here?
Mostly no. This one is spec behaviour, so all engines agree on the big causes. What differs is how old the tools are.
| Chrome / Edge | Firefox | Safari / iOS | |
|---|---|---|---|
position: sticky (unprefixed) | 56 / Edge 16 | 59 | 13 |
overflow: clip | 90 | 81 | 16 |
| Baseline | widely | widely | widely |
Both rows are from webstatus.dev, checked today. Sticky became Baseline "newly" on 2019-09-19 and "widely" on 2022-03-19. overflow: clip went newly on 2022-09-12 and widely on 2025-03-12. So the fix I'll recommend below is safe on anything your analytics will show you in 2026, and the -webkit-sticky prefix you keep copy-pasting is dead weight. You can delete it.
One honest caveat: Safari 16 is the floor for overflow: clip. If you still support Safari 15, @supports (overflow: clip) is the gate, and the fallback is the layout change in the fix section, not another overflow value.
What changes next? Nothing is scheduled to change the "nearest scroll container" rule. The CSS WG thread has floated ideas for years, and overflow: clip is the answer people converged on there. I wouldn't wait.
The short answer
position: sticky isn't "stick to the viewport". It's "stick inside the nearest ancestor that is a scroll container, and never leave my own parent". Most failures are one of those two clauses silently going wrong.
So the causes, in the order they bite real codebases:
- An ancestor has
overflowset to anything other thanvisibleorclip, so the element is sticking to a box that never scrolls. - The element is a stretched flex or grid item, so it's exactly as tall as its parent and has nowhere to travel.
- There's no inset (
top,bottom,left,rightor the logical versions) for the axis you're scrolling. - The parent is too short, so the bar runs out of room almost immediately.
htmlandbodyboth carry anoverflowvalue, sobodyquietly becomes the scroll container.- You're on a sticky
<thead>, adisplay: contentswrapper, or a transformed layout and expected something else. This is the grab bag.
What "sticky" is actually stuck to
Picture a sticky note on a window pane. It sticks to the nearest pane, not the building. If the pane you stuck it to is painted shut and never moves, the note never moves relative to anything you care about.
That's the mechanism. The spec says the inset properties are insets from the scrollport of the nearest scroll container with a matching scrollable axis, and that box defines the sticky view rectangle. MDN states the practical consequence: the element sticks to the nearest ancestor with a scrolling mechanism, created by overflow being hidden, scroll, auto or overlay, "even if that ancestor isn't the nearest actually scrolling ancestor".
Read that last clause again. An overflow: hidden wrapper that is exactly as tall as its content doesn't scroll anywhere, but it is still the box your element is stuck to. The sticky view rectangle is that wrapper's padding box. Your element's offset in it never changes when the page scrolls, so it never visibly sticks.
That's why overflow-x: auto alone kills it. Setting one axis to anything other than visible forces the other axis from visible to auto, per the overflow spec (MDN states the same rule, including the version for clip: if one axis is clip and the other is neither visible nor clip, clip computes as hidden). You never wrote overflow-y, but you got it anyway.
overflow: clip is the exception on purpose. It clips painting but doesn't create a scroll container, so there is no scrollport to stick to and sticky skips right past it to the next ancestor. MDN says it directly: clip is not a scroll container and doesn't support programmatic scrolling.
Now the other clause. Sticky also constrains the element to its containing block. A sticky bar can move inside its parent and no further. Cause 4 is that. Cause 2 is the sneaky version of it: in a flex row, align-items defaults to stretch, so an <aside> with no explicit height grows to the row's height. Its containing block is exactly its own size. There is zero room to move, so it can't visibly stick, however correct the CSS is. (My explicit height: 40px on the bar in the first test run hid this, by the way. A fixed height opts out of stretch. The aside that's meant to be tall is the one that breaks.)
Sticky elements also always create a stacking context, per MDN. That matters when your stuck bar slides under a dropdown and you start chasing z-index. Different bug, same element. Stacking contexts get their own article.
Wrong turns, then the ancestor walk
Dead end one: the parent needs position: relative. It's the first folk theory, and it came from every old answer I'd half-remembered. I added it. Nothing. The Computed pane had been saying position: sticky since the start, so the property wasn't being overridden or ignored. Sticky doesn't need a positioned parent at all. I took about forty minutes to accept that, because the advice is everywhere.
Dead end two: Tailwind purged my top-0. The app was on Tailwind, sticky top-0 in the class list, and I assumed the utility wasn't generated. Computed showed top: 0px. The class was there and the value was there. I had been looking at the Styles pane, which only tells you a rule matched. Open Computed, tick "Show all", and look at the actual values. That's two minutes I wanted back.
Neither was close. Here's the path that worked, and it works on your page too.
Step 1. Select the sticky element. Open Computed with "Show all" on. Confirm position: sticky and that the inset for your scroll axis isn't auto. If top says auto, that's cause 3 and you're done.
Step 2. Scroll a little and watch the element's top in the console:
js
// Run with the sticky element selected in Elements ($0).
// Move the page, then re-run it. A sticky bar should print 0 (for top: 0).
document.documentElement.scrollTo(0, 300);
console.log($0.getBoundingClientRect().top);Step 3. Walk up the ancestors and list every box that is a scroll container or clips:
js
// Every ancestor that turns the sticky element's scrollport into something
// other than the viewport. Anything printed here is a suspect.
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,
scrollH: el.scrollHeight,
clientH: el.clientHeight
});
}
}
console.table(hits);I tested that on a page with an overflow-x: auto wrapper around an overflow: clip section. It correctly listed both. Note that clip is in the list: it shows up but isn't a problem. Only hidden, scroll, auto and overlay are scroll containers. Anything printing visible on both axes is filtered out already.
Read the output like this. If scrollH equals clientH on a row, that ancestor never scrolls and you've found a box that exists purely to hold your sticky element hostage. That row is the one to fix.
Step 4. If no ancestor shows up, check the flex/grid stretch case:
js
const p = $0.parentElement;
console.log(getComputedStyle(p).display,
getComputedStyle($0).alignSelf,
$0.offsetHeight, p.offsetHeight);
// flex/grid parent, alignSelf 'normal' or 'stretch', and the two heights match → cause 2The tell. In my case it was the scrollH === clientH row. A wrapper that couldn't scroll and was nowhere near being a scroll container in my head. If the Computed pane says sticky, the inset is set, the parent is tall, and getBoundingClientRect().top still moves with the page, the problem is upstream. It's never the element.
Step 5. Last stop, html and body. If both have non-visible overflow, the spec stops propagating body's value to the viewport, so body becomes its own scroll container. That's the -300 row in the table. Put overflow-x: hidden on one of them, or neither. (I know that's an awkward thing to have to say.)
Ranked by frequency in real code, and I'd put money on this order:
- Ancestor
overflow(cause 1). By a mile. This was mine. - Stretched flex/grid item.
- Missing inset.
- Parent too short.
htmlandbodyboth overflowed.- Everything else.
Fixing it, cheapest first
Option A: change the ancestor, if you wrote it. Most of the time that overflow: hidden was added for one of three reasons: contain a float, hide a horizontal scrollbar, or clip a rounded corner.
diff
.layout-wrapper {
- overflow-x: hidden;
+ overflow-x: clip;
}clip clips painting but doesn't create a scroll container, so sticky ignores that ancestor. Baseline widely, as above. This is the one I'd ship. Be aware of what you give up: no programmatic scrolling in that box, and a focusable element inside the clipped area can receive focus without being scrolled into view, which MDN flags as a keyboard accessibility problem. Don't clip an area that can contain a form field or a link you need reachable.
Option B: don't use overflow for a formatting context.
diff
.card {
- overflow: hidden;
+ display: flow-root;
}If you used overflow: hidden to contain floats or stop margin collapse, display: flow-root does that without the scrollport side effect. It's the right answer for that job and has been for years.
Option C: stretched item.
diff
aside {
position: sticky;
top: 1rem;
+ align-self: start; /* flex-start works too in a flex row */
}In a grid, align-self: start is the one-liner. In flex, align-self: flex-start. Without it the aside is as tall as the row and has nowhere to go.
Option D: set the inset. top: 0. Use top: -1px for the sentinel-style trick if you're watching it with an IntersectionObserver, but that's a different article.
Option E: parent. Make the parent contain everything the bar should stick through. If the wrapper only wraps the header, the header can't stick past it. Put it in the same box as the content.
Things that look like fixes and aren't. position: relative on the parent, z-index: 9999 on the bar (sticky already creates a stacking context, so you're fixing the wrong thing), and a JavaScript scroll listener that toggles position: fixed. That last one works until it doesn't, and you'll own a layout-thrashing handler forever. Don't do it.
I don't love Option A as a long-term answer. It silences the symptom in the child when the real mistake was the overflow-x: hidden on the wrapper in the first place. If you can find the thing that's actually overflowing by 1px and fix that, do that. But Option A is correct and cheap, and it stays correct.
Why I'd stop using overflow: hidden as a layout reflex
For years overflow: hidden has been three things in people's heads: a clearfix, a "make the horizontal scrollbar go away", and a "clip the child to my border radius". Each of those has a more precise tool now.
| You wanted | Reach for |
|---|---|
| contain floats / stop child margins escaping | display: flow-root |
| clip painting to a rounded box | overflow: clip (plus overflow-clip-margin if you need slack) |
| stop a rogue child causing a horizontal scrollbar | find the child; overflow-x: clip on the wrapper as a backstop |
That wrapper pattern, written so it doesn't kill sticky:
html
<div class="shell">
<header class="topbar">...</header>
<div class="body">
<aside class="side">...</aside>
<main class="content">...</main>
</div>
</div>css
.shell { overflow-x: clip; } /* no scroll container, sticky survives */
.topbar {
position: sticky;
inset-block-start: 0; /* logical, so RTL and vertical writing are free */
z-index: 1;
}
.body {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
}
.side {
position: sticky;
inset-block-start: 4rem; /* clears the topbar */
align-self: start; /* the one that people forget */
max-block-size: calc(100dvh - 4rem);
overflow-y: auto; /* deliberate: the sidebar's own scroll */
}I ran that structure in Chromium 141 with a 2000px main column: both bars stick. The minmax(0, 1fr) is there because without it a wide table in .content blows out the grid and then somebody adds overflow-x: hidden to the shell and you've rebuilt this bug. Same root: min-width: auto and 1fr, which is its own article.
The sidebar's overflow-y: auto is fine, because it's on the sticky element itself and not on an ancestor. The element can be its own scroll container. It's only the ancestors that matter.
Making CI yell about overflow on layout wrappers
You can't lint "this ancestor has the wrong overflow" with a static rule. You can lint the habit, and you can test the behaviour.
Lint the habit with stylelint's declaration-property-value-disallowed-list:
json
{
"rules": {
"declaration-property-value-disallowed-list": {
"overflow": ["hidden"],
"overflow-x": ["hidden"],
"overflow-y": ["hidden"]
}
}
}That's blunt on purpose. Anyone who really needs it adds a one-line disable with a reason, and the reason shows up in review. If your design system is big, scope it to the layout directory with overrides.
Test the behaviour, which is the part that actually catches regressions. This runs across all three engines if your Playwright config has Chromium, Firefox and WebKit projects:
ts
import { test, expect } from '@playwright/test';
test('app shell topbar stays stuck', async ({ page }) => {
await page.goto('/dashboard');
await page.evaluate(() => window.scrollTo(0, 600));
const top = await page.locator('.topbar').evaluate(
el => Math.round(el.getBoundingClientRect().top)
);
expect(top).toBe(0);
});Add the same check for the sidebar and for any sticky table header. It fails the moment a teammate wraps the shell in overflow-x: hidden, and it fails in the browser that matters rather than the one the teammate used. Also useful: run it at a narrow viewport (390px), because layout shells change at breakpoints and sticky often dies only below one.
For iOS, a real iPhone over USB with Safari Web Inspector is better than Chrome's device emulator, which is Blink pretending. For this particular bug it won't matter much, since the behaviour is spec-driven, but when someone says "sticky is broken on iPhone only", look for a 100vh or safe-area wrapper with overflow first.
Worth pinning
- Sticky sticks to the nearest ancestor that's a scroll container, whether or not it ever scrolls. One
overflow-xon any wrapper is enough. - A flex or grid child with no height stretches to its parent's height, and a stretched box can't move inside itself.
align-self: start. overflow: clipclips without making a scroll container.display: flow-rootmakes a formatting context without one. Neither breaks sticky.- Debug in this order: Computed (
sticky, inset),getBoundingClientRect().top, ancestor walk, stretch check. The Styles pane is the wrong place to start. - Put a one-line Playwright test on the sticky things you can't afford to lose. Linting can't see this bug. A scroll can.
Neighbouring topics: overflow scroll containers ↔ position: sticky ↔ display: flow-root; flex align-items: stretch ↔ sticky sidebars; minmax(0, 1fr) ↔ grid blowout; stacking contexts ↔ z-index (sticky always creates one).
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.