Skip to content

CSS position: sticky Not Working: Causes and Fixes

position: sticky refusing to stick? It's almost always one of four things: a missing offset, a clipping ancestor, a too-short parent, or a table element. Here's how to find which.

css css-position position-sticky css-layout stacking-context browser-support frontend responsive-design
Bharath G
Reading Progress

On This Page

1. The Symptom

You set position: sticky on an element — a sidebar nav, a table header, a section label — and it does nothing. It scrolls away with everything else like it's still position: static. No console error, no crossed-out declaration in DevTools. The Styles pane shows position: sticky applied, not overridden, not invalid. The Computed pane confirms position: sticky. And yet the element moves with the page instead of sticking.

This is CSS's silent-failure mode at its most frustrating: every property involved parses fine, so nothing gets flagged. The failure is behavioral, not syntactic, and it comes from one of four unrelated preconditions not being met simultaneously. Get any one of them wrong and the whole thing quietly degrades to normal flow.

Applies identically across Chrome, Edge, Firefox, and Safari (desktop and iOS) — this isn't an engine quirk, it's the spec working as designed. The one real cross-browser wrinkle is historical: Safari needed -webkit-sticky before Safari 13 (September 2019); every browser people actually test against today accepts the unprefixed value.

The DevTools check that takes 10 seconds: open the Elements panel, select the sticky element, and look at the Layout pane (Chrome/Edge) or the badge next to position in the Styles pane. If Chrome doesn't show a "scroll" badge on the element and the element isn't sticking, inspect its ancestor chain in the Computed pane for overflow — that's precondition #2 below, and it's the one that catches people who inherited the layout from someone else.

2. How to Reproduce It

Here are four minimal, self-contained repros — one per precondition. Save each as its own .html file and open directly in a browser; no build step needed.

Repro A — missing offset (most common):

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
  body { height: 250vh; font-family: sans-serif; }
  .box { position: sticky; background: #ffd166; padding: 12px; width: 200px; }
  /* No top/bottom/left/right set — this is the bug */
</style>
</head>
<body>
  <div class="box">I should stick but won't</div>
</body>
</html>

Scroll down. The box scrolls away. Add top: 0; to .box and it sticks.

Repro B — a clipping/scrolling ancestor:

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
  body { font-family: sans-serif; }
  .scroll-wrapper { height: 300px; overflow-y: auto; border: 2px solid #333; }
  .content { height: 800px; }
  .sticky-header {
    position: sticky;
    top: 0;
    background: #06d6a0;
    padding: 8px;
  }
  .ancestor-with-overflow { overflow: hidden; } /* clips the sticky element */
</style>
</head>
<body>
  <div class="scroll-wrapper">
    <div class="ancestor-with-overflow">
      <div class="content">
        <div class="sticky-header">I have top:0 but still won't stick</div>
        <p>Scroll this box...</p>
      </div>
    </div>
  </div>
</body>
</html>

.sticky-header has a valid offset, but .ancestor-with-overflow sits between it and the scroll container with overflow: hidden — that alone breaks stickiness, even though .ancestor-with-overflow itself never scrolls.

Repro C — parent isn't taller than the sticky child:

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
  body { height: 250vh; font-family: sans-serif; }
  .parent { height: 60px; } /* same height as the child — nowhere to stick within */
  .sticky-child { position: sticky; top: 0; height: 60px; background: #ef476f; }
</style>
</head>
<body>
  <div style="height:100vh"></div>
  <div class="parent">
    <div class="sticky-child">I have no room to stick</div>
  </div>
</body>
</html>

The child's containing block (its parent) is exactly as tall as the child, so there's no scroll distance during which "stuck" and "not yet stuck" are different states — it appears never to stick at all.

Repro D — a table element:

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
  table { border-collapse: collapse; width: 100%; }
  thead th { position: sticky; top: 0; background: #118ab2; color: white; }
  tbody td, thead th { padding: 8px; border: 1px solid #ccc; }
</style>
</head>
<body>
  <div style="height:400px; overflow-y:auto;">
    <table>
      <thead><tr><th>Name</th><th>Score</th></tr></thead>
      <tbody id="rows"></tbody>
    </table>
  </div>
  <script>
    const rows = document.getElementById('rows');
    for (let i = 0; i < 40; i++) rows.innerHTML += `<tr><td>Row ${i}</td><td>${i * 3}</td></tr>`;
  </script>
</body>
</html>

This one usually does work in current engines — but the spec explicitly calls table-group/row/column/cell/caption behavior "undefined," so it's the one case where an identical-looking bug can surface only in a specific engine version, and it's worth knowing you're relying on interoperable-but-unspecified behavior, not a guarantee.

None of these need a bundler, framework, or toolchain — this is pure CSS, and the toolchain is irrelevant to which of the four preconditions is missing.

3. Browser & Baseline Support Matrix

position: sticky itself is evergreen — no feature-detection dance needed.

EngineSupports stickySinceBaseline
Chrome / Edge (Blink)YesChrome 56 (2017)Widely available
Firefox (Gecko)YesFirefox 32 (2014, -moz- never required)Widely available
Safari desktop (WebKit)Yes, unprefixedSafari 13 (2019); -webkit-sticky needed Safari 6.1–12Widely available
Safari iOS (WebKit)Yes, unprefixediOS Safari 13 (2019)Widely available
Samsung InternetYesLong supportedWidely available

MDN lists the feature as broadly available since July 2015 across the major engines, and it's been Baseline-safe for years — you can drop -webkit-sticky entirely unless you have a hard requirement to support Safari 12 or earlier (over six years EOL at this point). There's no @supports check that helps here, because a browser without sticky support simply parses it as an invalid value for position and falls back to the property's initial value (static) rather than throwing — which, confusingly, looks identical to the "not working" symptom this article is about, just for a different reason (browser age instead of missing precondition).

The four preconditions above are pure specification behavior (CSS Position Module Level 3), not implementation variance — every engine that supports sticky at all applies them identically. This is the rare case where cross-browser testing won't find bugs the spec doesn't already explain.

4. Why It Happens — Surface Level

sticky is not a standalone positioning mode — it's relative until a scroll threshold is crossed, then it acts like fixed relative to its nearest scrolling ancestor, until its own containing block runs out of room. Each of the four failures above removes a piece that behavior depends on: no offset means there's no threshold to trigger the switch; a clipping ancestor removes the visible area the element would stick within; a too-short parent removes the room to move; and table elements are simply outside the spec's guaranteed behavior.

The reason none of this throws an error is that sticky is a fully valid keyword for position in every case above — the browser isn't rejecting your CSS, it's correctly computing that the conditions for "stuck" never arise.

5. Why It Happens — Under the Hood

Per the CSS Position Module Level 3 spec, a stickily positioned box is first laid out exactly as position: relative would lay it out — normal flow, no offset applied yet. The browser then computes a sticky-constraint rectangle against two things: the element's containing block (its nearest block-level ancestor, including table-related elements per spec wording) and its nearest ancestor scroll container — the nearest ancestor whose overflow computes to anything other than visible (hidden, scroll, auto, or overlay), regardless of whether that ancestor's content is currently scrollable.

This is precisely why Repro B fails in a way that looks like a bug but isn't: overflow: hidden creates a scroll container even though nothing about it ever scrolls, and the sticky-positioning algorithm treats "nearest ancestor with non-visible overflow" as the relevant scrollport — not "nearest ancestor that visibly scrolls." Once that scrollport is established, if the sticky element's rendered box lies outside its clipped bounds, it can be visually clipped away entirely, which is functionally indistinguishable from "sticky isn't working."

The top/bottom/left/right you set define the offset from that scrollport's edge at which the element's flow position and its sticky position begin to diverge — the browser continuously compares the element's flow-position edge against scrollport edge ± offset and switches the used position between "flow" and "stuck" as that crossing happens. With no offset on an axis (auto on both top and bottom, say), there's no crossing condition defined on that axis, so per spec the element behaves as plain relative on that axis — not a bug, a documented fallback.

The "stops sticking at the bottom" behavior — Repro C — comes from the containing block, a separate concept from the scrollport. The element can't move past its containing block's own box; once the containing block's bottom edge reaches the sticky element's would-be position, it un-sticks and resumes flow. If the containing block is only as tall as the sticky child itself, that crossing point is identical to the starting point, so there's no distinguishable "stuck" state to observe — the element is never wrong, there's just no room for you to see it happen.

Also worth knowing: position: sticky unconditionally creates a new stacking context (per spec, regardless of z-index value), the same way position: fixed and opacity < 1 do. If a sticky header disappears behind other content rather than failing to stick at all, that's a related but distinct bug — check z-index within the new stacking context it creates, not the page's global stacking order (z-index values never compare across stacking-context boundaries; see the companion article on stacking contexts for the full mechanism).

6. The Fix

For Repro A — add an explicit offset:

  .box {
    position: sticky;
+   top: 0;
    background: #ffd166;
  }

Any non-auto value works, including 0. This is the fix in the overwhelming majority of real "sticky not working" reports.

For Repro B — remove or relocate the clipping overflow:

- .ancestor-with-overflow { overflow: hidden; }
+ .ancestor-with-overflow { overflow: visible; }

If that ancestor's overflow: hidden exists for a real reason (clipping a child, forming a BFC to contain floats), the correct fix is usually to move the sticky element outside that ancestor's DOM subtree, or to replace the overflow: hidden hack with the thing it was actually doing — most commonly display: flow-root when it was only there to contain floats (see section 7).

For Repro C — give the containing block real height:

- .parent { height: 60px; }
+ .parent { min-height: 400px; }

The parent needs to be taller than the sticky child by at least the scroll distance you want the child to remain stuck for.

For Repro D — don't rely on sticky on table-group elements across engines you haven't tested:

Wrap the <table> in a scrolling <div> and apply position: sticky to <th> (as shown), which is the pattern every major browser handles consistently in practice — but treat it as "works everywhere I've checked," not "guaranteed by spec," and add it to your visual regression suite (section 8) rather than assuming it forever.

What's a real fix vs. a workaround: removing overflow: hidden entirely and replacing it with display: flow-root is a real fix — it keeps the BFC-containment benefit without the side effect. Setting overflow: visible !important to force past a component library's internal overflow: hidden is a workaround: it silences the symptom without addressing why that library clips content, and it can resurface as a different visual bug (unclipped overflow) the next time that component's content changes shape.

7. Best Practices & The Better Design

Treat the four preconditions as a checklist you write into the component, not something you rediscover under deadline. A sticky header component is safest built like this:

.sticky-header {
  position: sticky;
  inset-block-start: 0; /* logical property — top in horizontal writing modes, RTL-safe */
  z-index: 10; /* documented value from a small z-index scale, not a guess */
  isolation: isolate; /* keeps this stacking context self-contained */
}

.scroll-region {
  overflow-y: auto;
  min-height: 0; /* required if this is itself a flex/grid child — see the flexbox article */
}

Using inset-block-start instead of top costs nothing today and means the component works unchanged in a right-to-left or vertical writing-mode context later — no separate RTL stylesheet needed.

For the "ancestor clips it" failure mode specifically, prefer display: flow-root over overflow: hidden whenever the only reason for the overflow was to contain floats or collapse margins — flow-root creates a block formatting context on purpose, without the side effect of clipping descendants or creating an accidental scrollport that breaks a sticky element three components away and two engineers later. This is the single highest-leverage change for preventing this whole bug class in a design system: a shared "container" or "card" component that defaults to overflow: hidden for layout-hygiene reasons is the most common source of "my sticky element three levels down doesn't work," because the person adding the sticky component has no reason to inspect an ancestor they didn't write.

8. How to Prevent It Long-Term

Add a stylelint rule of thumb to code review rather than tooling (there's no automated check for "does this sticky element have an ancestor with clipping overflow," since that requires cross-file, cross-selector reasoning stylelint doesn't do) — but you can catch the more common variant, a missing offset, informally by grepping for position:\s*sticky without a nearby top|bottom|left|right in the same rule block during review.

Where it's worth automating: add a Playwright visual-regression test for any sticky header/sidebar component that scrolls the page and screenshots before/after the scroll threshold, run across Chromium, Firefox, and WebKit projects — this catches the table-element interoperability gap (Repro D) and any future regression from a teammate wrapping the component in a new container with overflow: hidden. Pair it with a Lighthouse or manual check for scroll-behavior interactions and CLS, since a sticky element that pops in abruptly at the threshold is a common, easy-to-miss layout-shift source.

Document your z-index scale (a handful of named values — --z-sticky-header: 10, --z-modal: 100, etc.) in whatever ships with your design tokens, since position: sticky's unconditional new stacking context means teams that don't have a scale tend to escalate z-index values indefinitely trying to "fix" what's actually a stacking-context scoping issue, not a numeric one.

9. Key Takeaways

  • position: sticky fails silently for one of four reasons: no offset (top/bottom/left/right all auto), a clipping/scrolling ancestor (overflow other than visible anywhere between the element and the viewport), a containing block no taller than the element itself, or — rarely — a table-group element where the spec leaves behavior undefined.
  • The "clipping ancestor" case is the one that isn't your component's fault: overflow: hidden set for an unrelated reason (float containment, clipping a dropdown) on any ancestor breaks stickiness even though that ancestor never scrolls.
  • sticky is Baseline-safe everywhere; the only historical wrinkle is -webkit-sticky for Safari 12 and earlier, which you can drop unless you have a specific reason to support browsers from before 2019.
  • position: sticky always creates a new stacking context — if the element sticks but renders behind other content, that's a z-index/stacking-context bug, not a sticky bug (see the companion stacking-context article).
  • Prefer display: flow-root over overflow: hidden for BFC containment in any container that might someday hold a sticky descendant — it's the fix that prevents this bug class rather than just resolving one instance of it.
csscss-positionposition-stickycss-layoutstacking-contextbrowser-supportfrontendresponsive-design

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments