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

  1. Is there an offset? Sticky needs at least one of top, bottom, left or right. With none, MDN notes it is indistinguishable from relative positioning.
  2. 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;
}
  1. 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.
  2. 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.
  3. Is it sticking but hidden? A sticky element can sit underneath later content. Give it a z-index before 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.

One sticky element with top: 0, page scrolled 1,000px. Chromium 141.
What was set Result
Nothing on any ancestorSticks
overflow-x: hidden on html and bodyFails
overflow-x: hidden on body onlySticks
overflow-x: hidden on html onlySticks
overflow-x: clip on html and bodySticks
A wrapper div with overflow: hiddenFails
A wrapper div with overflow-x: autoFails
A wrapper div with overflow: clipSticks

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).

Clip hides the symptom, not the cause. Something on the page is wider than the screen, and clip only stops it from creating a scrollbar. If you reached for overflow-x because a page scrolls sideways on a phone, find the wide element first. The sideways-scroll guide measures the usual causes and gives a console snippet that names the offender.

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.

A sticky sidebar next to a 3,000px column. Chromium 141, top: 20px.
Layout Sidebar height Result
Flex row, default stretch3,000pxFails
Flex row, align-self: flex-start on the sidebar68pxSticks
Flex row, align-items: flex-start on the parent68pxSticks
Grid, default stretch3,000pxFails
Grid, align-items: start on the parent68pxSticks
.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

The two simplest failures. Chromium 141.
What was set Result Why
No top value (left at auto)FailsBehaves as relative positioning
Parent exactly as tall as the sticky elementFailsThe 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

Chromium only. Every result above is from Chromium 141 on small synthetic pages. The behaviour is defined by the CSS specifications, so other browsers should agree, but Firefox and Safari were not run. Ancestors with 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.

Badri Dutta

Software engineer · 8 years technical SEO

Written after the sticky header, the table of contents and the checklist score box on this site all stopped sticking at once. The cause was a one-line fix for something else, and nothing in the site's audits was looking for it.

Full background →