Skip to content

Position Sticky and Fixed

beginner10 min read

Sticky and Fixed: Simple Concept, Surprising Failures

position: sticky and position: fixed seem straightforward -- one sticks during scroll, one stays in place. Simple, right? Except both break in ways that puzzle even experienced developers. Sticky silently fails when overflow is set on an ancestor. Fixed breaks when a parent has a transform. Understanding why they fail requires understanding containing blocks and scroll containers.

Mental Model

Think of position: sticky as a magnet on a refrigerator door. The magnet (sticky element) slides freely until it hits the metal strip (the top offset). Then it sticks. But it can only stick within the refrigerator door (its parent container). When you scroll the parent out of view, the magnet goes with it — it doesn't stick to the wall. position: fixed is a sticker on the glass window — it stays in place relative to the viewport no matter what scrolls behind it, unless the window itself is inside a moving frame (ancestor with transform).

Position: Sticky — How It Works

.header {
  position: sticky;
  top: 0; /* Sticks when top of element reaches 0px from scroll container */
}

Sticky has three phases:

  1. Normal flow: Element scrolls with content like position: relative
  2. Stuck: When the scroll position hits the top offset, the element stops scrolling
  3. Unstuck: When the parent's bottom edge reaches the element, it scrolls away with the parent

The critical detail: sticky elements are bound by their parent. They can only stick within the parent container's box. When the parent scrolls out of view, the sticky element leaves with it.

/* Table of contents that sticks within a sidebar */
.sidebar {
  height: 2000px; /* Tall enough to scroll */
}

.toc {
  position: sticky;
  top: 2rem; /* Sticks 2rem from top of scroll container */
  /* Stops sticking when .sidebar's bottom edge reaches the TOC */
}

The Seven Reasons Sticky Fails

If your sticky element isn't sticking, the answer is almost certainly one of these.

1. Missing top/bottom/left/right

/* BROKEN: No offset specified */
.header { position: sticky; }

/* FIXED: Must specify at least one offset */
.header { position: sticky; top: 0; }

2. Ancestor Has overflow: hidden, auto, or scroll

/* BROKEN: Overflow on ancestor creates a new scroll container */
.wrapper { overflow: hidden; }
.header { position: sticky; top: 0; } /* Sticks within .wrapper, not viewport */

/* FIXED: Remove overflow from ancestors between sticky element and scroll container */
.wrapper { overflow: visible; }

/* Or use overflow: clip (doesn't create a scroll container) */
.wrapper { overflow: clip; }

3. Parent Has No Scrollable Height

/* BROKEN: Sticky element fills its parent — no room to scroll */
.parent { display: flex; }
.child { position: sticky; top: 0; flex: 1; }
/* Child IS the parent's full height — nowhere to stick */

/* FIXED: Ensure the parent is taller than the sticky element */

4. Parent Has Fixed Height Equal to Content

/* BROKEN: Parent's height equals its content — no overflow to scroll through */
.parent { height: auto; } /* height matches content exactly */
.sticky { position: sticky; top: 0; }
/* No scrollable area → sticky has nothing to stick against */

5. Ancestor Creates a New Containing Block

/* CAUTION: transform, filter, perspective on ancestor */
.ancestor { transform: translateX(0); }
/* Sticky still works, but now relative to this ancestor's scroll, not viewport */

6. display: flex or grid on the Parent (edge case)

/* POTENTIAL ISSUE: Sticky child in flex column might stretch */
.parent { display: flex; flex-direction: column; }
.sticky { position: sticky; top: 0; align-self: flex-start; }
/* align-self prevents the sticky element from stretching to parent height */

7. Parent Has overflow-x Set (Triggers overflow-y: auto)

/* SNEAKY: Setting overflow-x affects overflow-y */
.parent { overflow-x: hidden; }
/* overflow-y becomes auto — creating a scroll container that captures sticky */
Common Trap

The most common sticky failure: a parent with overflow: hidden set for a completely unrelated reason (hiding a decorative element's overflow, preventing horizontal scroll). The sticky element sticks within that overflow container instead of the viewport. The fix is overflow: clip instead of overflow: hidden — clip doesn't create a scroll container.

Position: Fixed -- And When It Breaks

Okay, now the other one.

.modal-overlay {
  position: fixed;
  inset: 0; /* top: 0, right: 0, bottom: 0, left: 0 */
  z-index: 1000;
}

Fixed positioning places the element relative to the viewport — unless a containing block captures it.

The Containing Block Trap

This is the gotcha that has ruined many developers' afternoons.

/* BROKEN: Fixed child doesn't position relative to viewport */
.parent {
  transform: scale(1); /* Creates a new containing block */
}

.modal {
  position: fixed;
  inset: 0; /* Now relative to .parent, NOT the viewport */
}

Properties that create a containing block for fixed elements:

  • transform (any value except none)
  • perspective (any value except none)
  • filter (any value except none)
  • backdrop-filter (any value except none)
  • contain: paint or contain: layout
  • will-change with transform, perspective, or filter

Fixed on Mobile: The 100vh Problem

/* BROKEN: Content hidden behind mobile URL bar */
.fullscreen { position: fixed; height: 100vh; }

/* FIXED: Use dynamic viewport height */
.fullscreen { position: fixed; height: 100dvh; }

/* Or: use inset instead of explicit dimensions */
.fullscreen { position: fixed; inset: 0; }
Execution Trace
Scroll start
Sticky element at natural position in flow
Behaves like position: relative
Scroll hits offset
Element's top reaches 'top: 0' threshold
Element stops scrolling — stuck
Check overflow
Is any ancestor overflow !== visible?
If yes, sticky relative to THAT scroll container
Parent boundary
Parent's bottom edge reaches sticky element
Element unsticks and scrolls away with parent
Fixed + transform
Ancestor has transform: translateZ(0)
Fixed child now relative to ancestor, not viewport

Production Scenario: Sticky Sidebar with Long Content

/* Problem: Sidebar taller than viewport can't be fully read */
.layout {
  display: grid;
  grid-template-columns: 1fr 300px;
  align-items: start; /* Prevents sidebar from stretching */
}

/* Solution: Sticky sidebar with max-height and scroll */
.sidebar {
  position: sticky;
  top: 5rem; /* Below fixed header */
  max-height: calc(100dvh - 6rem); /* Fit in viewport minus header */
  overflow-y: auto;
}

/* Alternatively: Sticky only the sidebar heading */
.sidebar-heading {
  position: sticky;
  top: 5rem;
  background: var(--bg); /* Cover content scrolling behind */
  z-index: 1;
}
What developers doWhat they should do
Forgetting to specify top (or bottom/left/right) on a sticky element
Without top/bottom/left/right, the browser doesn't know the sticky threshold
Sticky REQUIRES at least one inset property to know where to stick
Using overflow: hidden on ancestors and wondering why sticky breaks
overflow: hidden/auto/scroll creates a scroll container that captures sticky positioning
Use overflow: clip instead — it clips content without creating a scroll container
Using position: fixed for modals without checking for transform ancestors
transform on any ancestor makes fixed position relative to that ancestor, not the viewport
Render modals via React portals at the document root, or verify no ancestor has transform/filter
Using 100vh for fixed full-screen elements on mobile
100vh includes the area behind the URL bar on mobile — content gets cut off
Use 100dvh, 100svh, or inset: 0 to account for mobile browser chrome
Quiz
A sticky element with top: 0 doesn't stick. The parent has overflow: hidden. What's the fix?
Quiz
A fixed modal is inside a div with transform: translateZ(0). What happens?
Key Rules
  1. 1position: sticky requires at least one offset property (top, bottom, left, right)
  2. 2overflow: hidden/auto/scroll on ANY ancestor captures sticky — use overflow: clip instead
  3. 3Sticky elements are bound by their parent container — they unstick when the parent scrolls away
  4. 4position: fixed breaks when any ancestor has transform, filter, perspective, or contain: paint
  5. 5Use 100dvh or inset: 0 for fixed full-screen elements on mobile