The short version: position: fixed pins an element to the viewport and pulls it out of normal flow entirely, so it stays put no matter how far you scroll. position: sticky is a hybrid: the element scrolls along with the page like a normal in-flow element until it reaches a threshold you set (say top: 0), and from that point it pins, but only inside its own scroll container. Reach the bottom of that container and it lets go.
That last clause is where almost every "my sticky header does nothing" bug lives, so I'll spend most of this page on it.
fixed: glued to the viewport
.toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
}fixed removes the element from flow (the layout collapses as if it were not there, so siblings move up underneath it) and anchors it to the viewport's initial containing block. It does not move when you scroll. Use it for things that must stay on screen regardless of position: a top nav bar, a cookie banner, a back-to-top button, a fixed footer CTA. Because it leaves the flow, you almost always have to add padding to the body or the first content block so the fixed element does not sit on top of your content.
sticky: in flow, then pinned
.section-heading {
position: sticky;
top: 0;
}sticky keeps the element in normal flow. It behaves like a relative element, scrolling with everything else, until its containing block scrolls past the threshold. At that moment the browser "sticks" it at the threshold and holds it there while the rest of the section scrolls behind it. This is what you want for a section heading that stays visible while you read its section, a sidebar that follows you down the page, or a table header that hangs around as you scroll a long table.
The number one sticky bug: no threshold
position: sticky with no top, bottom, left, or right does nothing. There is no error, no warning, no visual hint. The element just scrolls away like normal content and you stare at it wondering why.
/* Broken: sticks to nothing */
.nope { position: sticky; }
/* Works: a threshold gives the browser something to stick to */
.yep { position: sticky; top: 0; }The threshold is the offset at which the element pins. top: 0 means "pin when you reach the top of the viewport." top: 1rem leaves a gap. You must set at least one of the four, and it is the first thing to check when sticky is silently failing.
The silent killers
Even with a threshold set, three things quietly defeat sticky. None of them throw an error.
An ancestor with overflow other than visible
This is the classic. A sticky element pins relative to the nearest ancestor that establishes a scrolling context, and overflow: hidden, overflow: auto, or overflow: scroll on any ancestor creates one. If that ancestor is not the element actually doing the scrolling (or has no scrollable height of its own), the sticky element gets trapped in a context where it can never reach its threshold, so it never pins.
/* Somewhere up the tree, often added for an unrelated reason */
.wrapper { overflow: hidden; }That one line, dropped on a parent to clip a stray shadow or contain a float, kills sticky on everything inside it. When sticky fails and the threshold is set, walk up the DOM and check every ancestor's overflow. It is almost always this.
A parent that is too short
Sticky pins only within its direct parent's box. The element can only travel as far as the bottom edge of that parent, then it unsticks and scrolls away with it. If the parent is barely taller than the sticky element itself, you get a flicker of stickiness and then nothing, which reads as "it doesn't work." A sticky sidebar needs a tall parent (the full content column) to have room to follow you down.
A containing block with no scrollable height
If the sticky element's containing block has no resolvable height to scroll within, there is nothing to stick against. Give the scroll container real height and make sure the sticky element is a genuine child of the thing that scrolls.
One useful escape hatch for the overflow trap above: if you only need horizontal clipping (the common reason a wrapper gets overflow: hidden), reach for overflow-x: clip instead. clip does not establish a scroll container, so it clips the overflow without killing sticky on the descendants, which overflow: hidden would.
fixed has its own trap: transformed ancestors
fixed is normally relative to the viewport, but if any ancestor has a transform, filter, perspective, backdrop-filter, contain: paint, or will-change on one of those properties set to anything other than none, that ancestor becomes the containing block instead. Your "fixed" element is now positioned relative to that ancestor and will scroll with it. A single transform: translateZ(0) added for GPU compositing, three levels up, is enough to break a fixed overlay or modal.
/* This anywhere up the tree re-anchors descendant `fixed` elements to it */
.parallax { transform: translateZ(0); }If a fixed element is mysteriously moving or clipped, search the ancestor chain for transform, filter, and will-change. Modals are usually the victims, which is why so many UI libraries portal their overlays to <body> to escape transformed wrappers.
Quick decision table
| Need | Reach for |
|---|---|
| Element that never moves while scrolling | fixed |
| Heading or sidebar that follows you down a section | sticky + top |
| Table header that stays while the table body scrolls | sticky on <th> + top |
| Overlay/modal that must ignore page scroll | fixed (portal to body) |
For a sticky table header, set position: sticky; top: 0 on the th cells themselves (not the thead, which historically did not pin in all engines) and give them a solid background so the scrolling rows do not show through.
If you are reaching for these positions to build a layout, the companion CSS pieces I keep going back to are computing sizes and offsets with calc() (handy for top: calc(var(--nav-height) + 1rem) thresholds), the :has() selector for styling a page based on whether a sticky element is present, and fixed-attachment background images, which share fixed's "relative to the viewport" mental model and the same transformed-ancestor gotcha.
See also
- Compute CSS sizes and offsets with calc(): build sticky thresholds like
top: calc(var(--nav-height) + 1rem)without magic numbers. - The CSS :has() selector: style a layout based on whether a sticky or fixed element is present in the page.
- Fixed (parallax-style) background images: shares
fixed's viewport-relative model, and the same transformed-ancestor surprise.
Sources
Authoritative references this article was fact-checked against.
- position - CSS reference (MDN)developer.mozilla.org
- Layout and the containing block (MDN)developer.mozilla.org
- overflow - CSS reference (MDN)developer.mozilla.org





