A sticky element sticks to its nearest scrolling ancestor, and only inside its parent's box. It fails when an ancestor has an overflow value other than visible (including overflow-x: hidden set on both html and body), when flex or grid stretches the item to full height, when the parent is no taller than the item, or when no top value is set.
Key takeaways
- The sticky rule is rarely the bug. Four causes covered every failure in the tests: ancestor overflow, a stretched item, a short parent and a missing offset.
- Any ancestor overflow other than visible or clip makes that ancestor the scroll container, even with no scrollbar and nothing to scroll. Sticky then sticks to that box, not to the page.
- html and body are a special case. overflow-x: hidden on body alone, or on html alone, left sticky working in Chromium. On both together, it broke.
- overflow-x: clip hides overflow without creating a scroll container, and it fixed every overflow case in the tests.
- In flex and grid, a sidebar stretches to the row's height by default, so it has nowhere to move. align-self: start (or flex-start) fixes it.
- Test by scrolling past the point where the element should stick. A short scroll on a tall page gave a false pass on this site.
The diagnosis, in the order that finds it fastest
- Is there an offset? Sticky needs at least one of
top,bottom,leftorright. With none, MDN notes it is indistinguishable from relative positioning. - Does any ancestor have overflow set? This is the usual culprit. Paste this into the DevTools console, with your own selector:
let el = document.querySelector('.your-sticky-element').parentElement;
while (el) {
const s = getComputedStyle(el);
if (s.overflowX !== 'visible' || s.overflowY !== 'visible') {
console.log(el.tagName, el.className, s.overflowX, s.overflowY);
}
el = el.parentElement;
}
- Is the item stretched? Select it in DevTools. If its height equals its whole parent's height, flex or grid stretched it and there is no room to move.
- Is the parent tall enough? Sticky only moves inside its parent's box. A parent that is barely taller than the item gives it nowhere to travel.
- Is it sticking but hidden? A sticky element can sit underneath later content. Give it a
z-indexbefore assuming it failed.
Cause 1: an ancestor with overflow
A sticky element is offset relative to its nearest ancestor that has a scrolling
mechanism. MDN describes that as created when overflow is hidden,
scroll, auto or overlay, even if that ancestor is
not the one actually scrolling. So overflow: hidden on a wrapper turns the
wrapper into the thing your element sticks to. If the wrapper simply grows with its
content, it never scrolls, and the sticky element scrolls away with the page.
There is a second rule that catches people out. Per the CSS spec, if one axis is set to
something other than visible or clip, the other axis can no
longer stay visible: it computes to auto. The console snippet
above shows it. overflow-x: hidden alone reports
overflow-y: auto.
| What was set | Result |
|---|---|
| Nothing on any ancestor | Sticks |
| overflow-x: hidden on html and body | Fails |
| overflow-x: hidden on body only | Sticks |
| overflow-x: hidden on html only | Sticks |
| overflow-x: clip on html and body | Sticks |
| A wrapper div with overflow: hidden | Fails |
| A wrapper div with overflow-x: auto | Fails |
| A wrapper div with overflow: clip | Sticks |
Why html and body behave differently
Per the CSS spec's
overflow propagation rule,
the browser applies overflow set on body to the viewport when
html is left at visible, so body itself never becomes a scroll
container. Set it on both and html's value goes to the viewport while body keeps its
own, which turns body into a box that can scroll but never does. That is the combination
that failed above. The console snippet will list BODY even in the harmless
body-only case, so read its output together with this rule: it only matters when
html is also not visible.
The fix
/* hides sideways overflow, but breaks sticky when set on both */
html, body { overflow-x: hidden; }
/* hides sideways overflow without creating a scroll container */
html, body { overflow-x: clip; }
overflow: clip clips like hidden but, per MDN, is not a scroll
container and cannot be scrolled programmatically. Current Chrome, Firefox and Safari
support it (check caniuse
for your own support floor).
Cause 2: flex and grid stretch the item
A sidebar in a flex or grid layout gets align-items: stretch by default, so
it is as tall as the row. An element as tall as its parent has no room to move inside
it, and sticky has nothing to do. The measured sidebar below was 3,000px tall until
alignment was changed.
| Layout | Sidebar height | Result |
|---|---|---|
| Flex row, default stretch | 3,000px | Fails |
| Flex row, align-self: flex-start on the sidebar | 68px | Sticks |
| Flex row, align-items: flex-start on the parent | 68px | Sticks |
| Grid, default stretch | 3,000px | Fails |
| Grid, align-items: start on the parent | 68px | Sticks |
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 250px;
align-items: start; /* without this the sidebar is as tall as the article */
}
.toc { position: sticky; top: 92px; }
This is why a sticky header often works while a sticky sidebar on the same page does
not: the header is usually a direct child of body and is never stretched.
Causes 3 and 4: a short parent, or no offset
| What was set | Result | Why |
|---|---|---|
No top value (left at auto) | Fails | Behaves as relative positioning |
| Parent exactly as tall as the sticky element | Fails | The element can only travel inside its parent's box |
Case study: three sticky elements that stopped sticking at once
This site hit cause 1 and cause 2 together. A mobile page was scrolling sideways, and the
quick fix was overflow-x: hidden on both html and
body. From that point the sticky site header, the checklist tools' score
box and the desktop table of contents all scrolled away with the page.
The table of contents was already built correctly for cause 2 (a grid with
align-items: start), and it still failed. That is the last row of the grid
test: sidebar height 68px, correct alignment, and with html and body both set to
overflow-x: hidden the sidebar's top ended at -980px after a 1,000px scroll.
Switching both rules to overflow-x: clip restored all three elements.
Two lessons are worth keeping. First, none of the site's audits would have caught it: they checked for console errors and sideways scrolling, and nothing checked whether a sticky element stayed stuck. Second, the first manual check passed by mistake. The page was about 25,000px tall and the test scrolled only 1,500px, which never reached the point where the element should have stuck. Scroll well past the element's natural position before trusting a result:
await page.evaluate(() => window.scrollTo(0, 2000));
await page.waitForTimeout(300); // let any smooth scrolling finish
const top = await page.locator('.site-header')
.evaluate(el => el.getBoundingClientRect().top);
console.log(top === 0 ? 'sticks' : 'broken');
What this does not cover
transform or
contain, sticky table headers and display: contents were not
tested either.
Sticky elements also shape how a page feels on a phone, which the pre-launch checklist covers under "open it on an actual phone", and how navigation behaves, one of the checks in the UI/UX design checklist.