Position Sticky and Fixed
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.
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:
- Normal flow: Element scrolls with content like
position: relative - Stuck: When the scroll position hits the
topoffset, the element stops scrolling - 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 */
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 exceptnone)perspective(any value exceptnone)filter(any value exceptnone)backdrop-filter(any value exceptnone)contain: paintorcontain: layoutwill-changewith 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; }
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 do | What 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 |
- 1position: sticky requires at least one offset property (top, bottom, left, right)
- 2overflow: hidden/auto/scroll on ANY ancestor captures sticky — use overflow: clip instead
- 3Sticky elements are bound by their parent container — they unstick when the parent scrolls away
- 4position: fixed breaks when any ancestor has transform, filter, perspective, or contain: paint
- 5Use 100dvh or inset: 0 for fixed full-screen elements on mobile