TechEarl

position: fixed vs sticky in CSS (and Why sticky Silently Fails)

fixed pins to the viewport and leaves the flow; sticky scrolls with content until it hits a threshold, then pins inside its scroll container. The difference, and why sticky so often does nothing at all.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
position: fixed pins an element to the viewport; position: sticky scrolls with content until a threshold and then pins. The difference and the silent sticky-failure traps.

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

css
.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

css
.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.

css
/* 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.

css
/* 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.

css
/* 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

NeedReach for
Element that never moves while scrollingfixed
Heading or sidebar that follows you down a sectionsticky + top
Table header that stays while the table body scrollssticky on <th> + top
Overlay/modal that must ignore page scrollfixed (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

Sources

Authoritative references this article was fact-checked against.

TagsCSSpositionstickyfixedsticky headercontaining blocklayout

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

find -exec vs xargs: Which to Use (and the {} + Trick That Beats Both)

find -exec ... {} + and find -print0 | xargs -0 are roughly equivalent for batch operations on matched files. find -exec ... {} \; forks once per match and is much slower. The decision matrix: when -exec is enough, when xargs adds value, and the safety rules for filenames with spaces, newlines, and quotes.

Responsive Images: srcset, sizes, and the picture Element

Ship responsive images with native HTML: srcset width descriptors plus sizes for resolution switching, picture with media for art direction, and source type for AVIF/WebP fallback. Plus the sizes gotcha that quietly defeats the whole thing.