Skip to content

Fix: CSS position: sticky Not Working

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix CSS position sticky not working — element scrolls away instead of sticking, caused by overflow hidden on a parent, missing top value, wrong stacking context, or incorrect height on the container.

The Error

You set position: sticky on an element but it scrolls away with the page instead of sticking. There is no error — the element just behaves like position: relative.

Common symptoms:

  • A sticky header scrolls off the screen instead of staying at the top.
  • A sticky sidebar stops working after adding overflow to a parent element.
  • position: sticky works in one browser but not another.
  • The element sticks at the wrong position.
  • The sticky element sticks only briefly then disappears.
  • Adding z-index to make it appear above other content breaks the stickiness.

Why This Happens

position: sticky is a hybrid of relative and fixed — the element scrolls with the page until it hits the threshold (top, bottom, left, or right), then sticks. It fails when:

  • No top, bottom, left, or right value is set — without a threshold, sticky has no reference point.
  • A parent or ancestor has overflow: hidden, overflow: auto, or overflow: scroll — the element sticks relative to its scroll container, which in this case is the overflowing ancestor, not the viewport.
  • The parent container is not tall enough — sticky only works while the element is inside its parent. If the parent is the same height as the sticky element, there is nowhere to scroll.
  • The sticky element is a flex or grid child with align-items: stretch — the element fills its container and has no room to stick.
  • Wrong stacking contextz-index, transform, filter, or will-change on an ancestor creates a new stacking context that clips stickiness.

The deeper reason most “broken sticky” reports come in is that the spec defines sticky relative to the nearest scrolling ancestor, not the viewport. The browser walks up the DOM and picks the first ancestor whose overflow is anything other than visible as the scroll container. If that ancestor never overflows — because its content is short — there is no scroll position for sticky to react to. The element therefore behaves like position: relative, which looks identical to “doing nothing.” Once you internalize this rule, almost every sticky failure resolves to one of two questions: which ancestor is the scroll container, and does it actually scroll?

The second underlying cause is the containing block. Sticky positions the element using the threshold (top, etc.) measured from the padding box of the containing block, which is typically the parent. If the parent itself only spans the height of the sticky child, the child has nowhere to slide. Sticky is a constraint between the scrollport, the element’s natural position, and the parent’s bounds; remove any of the three and the constraint collapses. A useful diagnostic mental model: imagine the sticky element on a rail bounded by its parent, and ask whether the rail is longer than the element.

Platform and Environment Differences

position: sticky is a CSS feature that, despite a stable spec, behaves with subtle differences across browsers, devices, and surrounding properties. Understanding these differences saves you from chasing a phantom bug:

  • Safari and iOS Safari prefix history. Safari shipped sticky with the -webkit-sticky prefix before unprefixing in 13. Production-quality CSS still includes both position: -webkit-sticky followed by position: sticky when supporting older iOS devices that linger in the wild. iOS Safari additionally has known quirks with sticky inside scrollable iframes and PDF previews, and the address-bar collapse can shift the visual viewport without a resize event, causing sticky offsets to look momentarily wrong.
  • Chrome vs Firefox repaint behavior. Chrome (Blink) and Firefox (Gecko) both implement sticky, but their composited-layer promotion differs. Chrome aggressively promotes sticky elements onto their own layer to avoid main-thread repaint on scroll; Firefox does this less often. Visually identical CSS can therefore have different performance characteristics — janky sticky in Firefox often improves after adding will-change: transform to the sticky element, while Chrome does not need the hint.
  • Edge legacy lacked sticky. Pre-Chromium Edge (EdgeHTML) supported -ms-sticky only in early builds and dropped it. If you support an enterprise audience still on legacy Edge or any version of Internet Explorer, sticky degrades to relative. Provide a static-position fallback or a JS polyfill rather than assuming everyone is on a current browser.
  • Mobile viewport when scrolled vs not. The URL bar collapses as the user scrolls down on mobile Safari and Chrome on Android, changing the viewport height. Sticky elements that use 100vh for sizing reflow when this happens. Switch to 100dvh (dynamic) or 100svh (small) to avoid layout jitter.
  • Scroll-snap interaction. scroll-snap-type on a scroll container can interact with sticky descendants in confusing ways. The sticky child snaps with the scroller and may visually “jump” between snap points instead of smoothly sticking. Either move the sticky element outside the scroll-snap container, or disable snapping for the segment that contains sticky content.
  • Parent overflow hidden trap. A frequent regression source is a CSS reset or a third-party component (modal portals, image galleries) that sets overflow: hidden on a wrapper. Sticky descendants stop working without warning. Use overflow-x: clip instead of overflow: hidden where horizontal clipping is the only goal — clip does not establish a scroll container.

Fix 1: Always Set a top (or bottom) Value

position: sticky requires at least one directional offset. Without it, the browser does not know where to stick the element:

Broken — no threshold:

.header {
  position: sticky;
  /* Missing top: 0 — sticky has no effect */
  background: white;
}

Fixed:

.header {
  position: sticky;
  top: 0; /* Sticks when it reaches the top of the scroll container */
  background: white;
  z-index: 100;
}

Use top for elements that stick at the top, bottom for those that stick at the bottom. You can also use top: 20px to leave a gap between the element and the top of the viewport.

Pro Tip: Always add a z-index to sticky elements. As you scroll, content passes underneath the sticky element — without a z-index, other elements may render on top of it, making it appear broken when it is actually working correctly.

Fix 2: Remove overflow: hidden from Parent Elements

This is the most common cause of broken sticky positioning. Any ancestor with overflow set to hidden, auto, or scroll becomes the sticky element’s scroll container — and if that container does not scroll, the element sticks within it but appears not to work.

Broken:

.page-wrapper {
  overflow: hidden; /* Breaks sticky for all descendants */
}

.sidebar {
  position: sticky;
  top: 20px;
  /* Sticks inside .page-wrapper, but .page-wrapper doesn't scroll — appears broken */
}

Fixed — remove overflow or change to overflow-x only:

.page-wrapper {
  /* Remove overflow: hidden entirely */
  /* Or if you only need to hide horizontal overflow: */
  overflow-x: clip; /* clip does not create a scroll container (unlike hidden) */
}

.sidebar {
  position: sticky;
  top: 20px;
}

overflow: clip vs overflow: hidden: overflow: hidden creates a new scroll container, breaking sticky. overflow: clip (CSS Overflow Level 4) clips content without creating a scroll container, preserving sticky behavior. Browser support: Chrome 90+, Firefox 81+, Safari 16+.

Find the problematic ancestor:

// Run in browser console to find which ancestor has overflow set
let el = document.querySelector(".your-sticky-element");
while (el) {
  const style = getComputedStyle(el);
  if (["auto", "scroll", "hidden"].includes(style.overflow) ||
      ["auto", "scroll", "hidden"].includes(style.overflowY)) {
    console.log("Found overflow ancestor:", el, style.overflow, style.overflowY);
  }
  el = el.parentElement;
}

Fix 3: Ensure the Parent Container Is Taller Than the Sticky Element

Sticky only works within the element’s parent container. If the parent is the same height as the sticky element, or shorter, there is no scroll distance within the parent and the element never sticks:

Broken — parent is same height as sticky element:

<div class="card"> <!-- height: 200px -->
  <div class="sticky-label">Label</div> <!-- height: 200px, position: sticky -->
  <!-- No content below sticky element in the parent -->
</div>

Fixed — parent must be taller than the sticky element:

<div class="container"> <!-- height: 1000px -->
  <div class="sticky-sidebar"> <!-- position: sticky; top: 0 -->
    <!-- sidebar content -->
  </div>
  <div class="main-content"> <!-- lots of content -->
    <!-- This content makes the container tall enough -->
  </div>
</div>

The sticky element sticks as long as it is within its parent. Once the parent scrolls out of view, the sticky element goes with it. This is intentional — it is how sticky works within table cells, sidebars, and sectioned lists.

Fix 4: Fix Sticky in Flexbox and Grid

When the sticky element is a flex or grid child, the parent’s alignment settings can prevent sticking:

Broken — flex child fills full height:

.layout {
  display: flex;
  align-items: stretch; /* Default — flex children fill the container height */
}

.sidebar {
  position: sticky;
  top: 0;
  /* Sidebar is stretched to match .main-content height — no room to stick */
}

Fixed — use align-self on the sticky element:

.layout {
  display: flex;
  align-items: stretch;
}

.sidebar {
  position: sticky;
  top: 0;
  align-self: flex-start; /* Don't stretch — let sticky work */
}

align-self: flex-start prevents the sidebar from being stretched to the container height, allowing it to scroll and stick independently.

For grid:

.grid-layout {
  display: grid;
  grid-template-columns: 250px 1fr;
  align-items: start; /* Or use align-self: start on the sticky child */
}

.sticky-sidebar {
  position: sticky;
  top: 20px;
}

Fix 5: Fix z-index and Stacking Context Issues

transform, filter, perspective, will-change, and some other CSS properties create a new stacking context on an ancestor, which can interfere with sticky positioning:

Broken — transform on ancestor breaks sticky:

.animated-wrapper {
  transform: translateZ(0); /* Creates stacking context — breaks sticky descendants */
  /* Also: will-change: transform; filter: blur(0); */
}

.sticky-header {
  position: sticky;
  top: 0;
  /* May not stick correctly if inside .animated-wrapper */
}

Diagnosis — check for transforms on ancestors:

let el = document.querySelector(".sticky-header");
while (el) {
  const style = getComputedStyle(el);
  if (style.transform !== "none" || style.willChange !== "auto" || style.filter !== "none") {
    console.log("Stacking context on ancestor:", el, {
      transform: style.transform,
      willChange: style.willChange,
      filter: style.filter,
    });
  }
  el = el.parentElement;
}

Fix — move the transform to a different element or restructure the DOM so the sticky element is not inside the transformed ancestor.

Sticky element’s own z-index:

.sticky-header {
  position: sticky;
  top: 0;
  z-index: 50; /* Must be higher than overlapping content */
  background: white; /* Opaque background to cover scrolled content */
}

Without an opaque background, content scrolling behind the sticky element shows through.

Fix 6: Fix Sticky Table Headers

position: sticky works on <th> and <td> elements for sticky table headers — but requires specific setup:

.table-container {
  overflow-y: auto;   /* The TABLE must scroll, not the page */
  max-height: 400px;  /* Give the container a fixed height */
}

table {
  border-collapse: collapse;
  width: 100%;
}

thead th {
  position: sticky;
  top: 0;
  background: white; /* Required — otherwise rows show through */
  z-index: 1;
}
<div class="table-container">
  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Age</th>
      </tr>
    </thead>
    <tbody>
      <!-- Many rows -->
    </tbody>
  </table>
</div>

border-collapse: collapse issue: When using border-collapse: collapse, sticky headers may lose their border on scroll. Fix by using box-shadow instead of border:

thead th {
  position: sticky;
  top: 0;
  background: white;
  box-shadow: 0 1px 0 #ccc; /* Replaces the bottom border */
}

Fix 7: Fix Sticky on Mobile

position: sticky is well-supported in modern browsers (Chrome 56+, Firefox 59+, Safari 13+). On older iOS Safari (below 13), it requires the -webkit- prefix:

.sticky-element {
  position: -webkit-sticky; /* Safari < 13 */
  position: sticky;
  top: 0;
}

iOS Safari-specific issue with 100vh: On iOS, 100vh includes the browser UI (address bar), causing height calculation issues with sticky elements. Use dvh (dynamic viewport height) instead:

.full-height-container {
  min-height: 100dvh; /* Adjusts for iOS browser UI */
}

Still Not Working?

Test in isolation. Create the simplest possible sticky example (one sticky element, one tall parent, one threshold value) and verify it works. Then add complexity back until it breaks — that identifies the culprit.

Check position: sticky support for your use case. Sticky within table cells, multi-column layouts, and certain flex/grid configurations can have browser-specific quirks. Check MDN for known limitations.

Check if JavaScript is overriding position. If a script sets element.style.position = "relative" or similar after page load, it overrides your CSS. Check the Computed styles in DevTools and look for inline styles overriding your stylesheet.

Inspect the scroll container in DevTools. Chrome and Firefox both surface a sticky badge in the Elements panel when sticky is active. If the badge never appears as you scroll, the browser has either rejected your sticky declaration or chosen a different scroll container than you expected. Hover the badge to see the live offsets used by the browser.

Watch for contain: paint or contain: layout on ancestors. CSS containment can isolate a subtree to optimize rendering, and paint/layout containment makes the element a containing block for fixed and sticky descendants. This silently traps sticky inside a small container.

Check for position: relative containers stacking on top. Stacking order matters even when sticky technically works: if a position: relative sibling with a higher z-index overlaps the sticky element, the sticky element looks frozen behind content. Use the 3D View in DevTools or temporarily add an outline to confirm.

Verify Safari’s top: env(safe-area-inset-top) math on notched devices. Sticky headers on iPhone with safe-area insets can appear partially behind the notch when the threshold is top: 0. Use top: env(safe-area-inset-top) to push the sticky element below the notch.

For related layout issues, see Fix: CSS Flexbox not working and Fix: CSS Grid not working. For z-index stacking issues, see Fix: CSS z-index not working. For scroll-related rendering, see Fix: CSS Scroll Behavior Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles