Dotted pixel art of an orange sunset glow among dark clouds, fading to near black at the bottom.

position: fixed and position: sticky

Author
gunlaug team
Updated
8 Nov 2026
Reading time
8 min

Photo by Optical Chemist

A fixed box that scrolls away and a sticky box that won't stick usually come down to one line of CSS on an ancestor. We scrolled test pages in three browser engines and measured which lines do it.

Try both in a frame

The frame below is its own viewport, so its fixed box stays pinned to the frame. Pick a value, then scroll inside the frame and watch the numbers.

Measuring

What traps a fixed box

We put a box with position: fixed and top: 10px inside a wrapper, gave the wrapper one declaration, scrolled the window 300 pixels and measured the box. Viewport means it stayed 10 pixels from the top of the window. Ancestor means it stayed 10 pixels from the top of the wrapper and scrolled off with it.

What a fixed box was positioned against, by declaration on its ancestor
CSS on the ancestorChromeFirefoxWebKit
transform: translateX(0)AncestorAncestorAncestor
transform: translateZ(0)AncestorAncestorAncestor
translate: 0AncestorAncestorAncestor
rotate: 0degAncestorAncestorAncestor
scale: 1AncestorAncestorAncestor
transform-style: preserve-3dAncestorAncestorAncestor
perspective: 100pxAncestorAncestorAncestor
filter: blur(0)AncestorAncestorAncestor
backdrop-filter: blur(2px)AncestorAncestorAncestor
-webkit-backdrop-filter: blur(2px)ViewportViewportAncestor
contain: layoutAncestorAncestorAncestor
contain: paintAncestorAncestorAncestor
contain: contentAncestorAncestorAncestor
contain: strictAncestorAncestorAncestor
content-visibility: autoAncestorAncestorAncestor
will-change: transformAncestorAncestorAncestor
will-change: perspectiveAncestorAncestorAncestor
will-change: filterAncestorAncestorAncestor
will-change: backdrop-filterAncestorAncestorAncestor
will-change: containAncestorAncestorAncestor

Values that change nothing on screen still count. filter: blur(0) and translate: 0 trapped the box the same way a real blur or move would. A percentage width followed the same rule: width: 50% measured 150 pixels against the 300 pixel wrapper and 500 against the window. Every declaration in the table captured absolutely positioned children too.

The one difference is the prefixed backdrop filter. WebKit accepts -webkit-backdrop-filter and traps the box. Chrome and Firefox don’t parse it, so they don’t.

will-change traps ahead of time, which makes it easy to miss. The will-change spec requires it to create the same containing block the named property would, even if that property never gets set. A running animation of transform also trapped the box in all three browsers.

Values that left it alone

Declarations that did not change what a fixed box was positioned against
CSS on the ancestorChromeFirefoxWebKit
transform: noneViewportViewportViewport
filter: noneViewportViewportViewport
backdrop-filter: noneViewportViewportViewport
perspective: noneViewportViewportViewport
contain: sizeViewportViewportViewport
contain: styleViewportViewportViewport
contain: inline-sizeViewportViewportViewport
container-type: inline-sizeViewportViewportViewport
container-type: sizeViewportViewportViewport
will-change: opacityViewportViewportViewport
will-change: topViewportViewportViewport
overflow: hiddenViewportViewportViewport
opacity: 0.5ViewportViewportViewport
clip-path: inset(0)ViewportViewportViewport
position: relativeViewportViewportViewport

Among the containment values, only the ones that include layout or paint containment trap the box. container-type, which you may have on a card for container queries, left it on the viewport in these builds.

On html and body

The same declarations on the root elements affect every fixed box on the page. The three browsers gave the same answer in each case.

What a fixed box was positioned against, with the declaration on html or body
CSSOn htmlOn body
transform: translateX(0)AncestorAncestor
perspective: 100pxAncestorAncestor
filter: blur(0)ViewportAncestor
backdrop-filter: blur(2px)ViewportAncestor
contain: paintAncestorAncestor
will-change: transformAncestorAncestor
will-change: filterViewportAncestor

filter and backdrop-filter on html are harmless, which matches the exception for the document root in the Filter Effects spec. On body they trap everything, so a page-wide filter, such as a dark mode built on invert(), belongs on html.

Clipping

This box was placed outside its 200 pixel tall wrapper, and we asked the browser what was painted at that spot.

Whether a fixed box outside its wrapper was still painted
CSS on the ancestorAll three browsers
overflow: hiddenVisible
overflow: clipVisible
position: relative; overflow: hiddenVisible
transform: translateX(0); overflow: hiddenClipped
filter: blur(0); overflow: hiddenClipped
will-change: transform; overflow: hiddenClipped
clip-path: inset(0)Clipped
contain: paintClipped

A fixed box ignores overflow on its ancestors, including one with position: relative, until one of those ancestors also traps it. Then the overflow clips it like any other child. clip-path and contain: paint clip it without help.

Dialogs and popovers escape

A popover and a modal dialog stayed on the viewport inside every wrapper we tried, because both render in the top layer. A dialog opened with show() isn’t in the top layer, and all three wrappers trapped it.

What a fixed popover or dialog was positioned against
Element, how it openedtransform: translateX(0)filter: blur(0)contain: paint
popover, showPopover()ViewportViewportViewport
dialog, showModal()ViewportViewportViewport
dialog, show()AncestorAncestorAncestor

No inset values

A fixed box with no top or left takes its normal place in the flow, 400 pixels down on our page, and holds that distance from the window as you scroll.

Dotted pixel art of white and pale yellow clouds streaked across a deep blue sky.Photo by Tuấn Vũ

Sticky needs an inset

For the sticky tests a 50 pixel box started 400 pixels down inside a 1000 pixel parent, and the window scrolled to 600. Sticks means the box stopped at its inset. Scrolls away means it carried on with the page.

Whether a sticky box held its position, by inset value
InsetChromeFirefoxWebKit
top: 10pxSticksSticksSticks
top: 0SticksSticksSticks
top: 5%SticksSticksSticks
top: -20pxSticksSticksSticks
inset: 10pxSticksSticksSticks
inset-block-start: 10pxSticksSticksSticks
No inset at allScrolls awayScrolls awayScrolls away
left: 10px onlyScrolls awayScrolls awayScrolls away
right: 10px onlyScrolls awayScrolls awayScrolls away

An inset only works on its own axis, so left does nothing for vertical scrolling. bottom: 10px works in the other direction: a box low in its parent rose to 10 pixels above the bottom of the window. When we placed the same box with margin-top: 950px and no spacer, it didn’t move at all, because a margin counts against the room the box has to travel.

Overflow on any ancestor

A sticky box attaches to the nearest ancestor with a scrollport, as the positioning spec defines it. An ancestor with overflow: hidden counts even if nothing inside it can scroll. Parent and grandparent gave identical results.

Whether a sticky box held its position, by overflow on its grandparent
CSS on the grandparentChromeFirefoxWebKit
overflow: visibleSticksSticksSticks
overflow: hiddenScrolls awayScrolls awayScrolls away
overflow: autoScrolls awayScrolls awayScrolls away
overflow: scrollScrolls awayScrolls awayScrolls away
overflow-x: hiddenScrolls awayScrolls awayScrolls away
overflow-y: hiddenScrolls awayScrolls awayScrolls away
overflow-x: autoScrolls awayScrolls awayScrolls away
overflow: clipSticksSticksSticks
overflow-x: clipSticksSticksSticks
overflow-y: clipSticksSticksSticks

overflow-x: hidden breaks vertical sticking because the overflow spec turns the other axis from visible into auto. overflow-x: clip has no such side effect. It cut off a 2000 pixel child in a 300 pixel wrapper, the document stayed 1000 pixels wide, and sticky kept working, so it is the replacement to reach for.

Transforms and filters are no problem for sticky. All 10 of the visual properties we put on the parent, including transform, filter and contain: paint, left it working. An inner box with its own scrollbar works too: the sticky box holds its place within that box.

html and body

Whether a sticky box held its position, by overflow on html and body
CSSChromeFirefoxWebKit
body overflow-x: hiddenSticksSticksSticks
html overflow-x: hiddenSticksSticksSticks
html and body overflow-x: hiddenScrolls awayScrolls awayScrolls away
html clip, body hiddenScrolls awayScrolls awayScrolls away
html hidden, body clipSticksSticksSticks
html and body overflow-x: clipSticksSticksSticks

overflow-x: hidden on body alone is fine, because the browser applies it to the viewport. Set it on html as well and body keeps it, becoming a scroll container that never scrolls. Sticky boxes attach to it and go nowhere. This is the combination to look for in a global stylesheet.

A parent with room

A sticky box can’t leave its parent, so it needs a parent taller than itself. The usual failure is a sidebar in a flex or grid row, where the default stretch makes it as tall as the row.

Whether a sticky box held its position, by layout
LayoutChromeFirefoxWebKit
Flex item, default stretchScrolls awayScrolls awayScrolls away
Flex item, align-self: flex-startSticksSticksSticks
Flex container, align-items: flex-startSticksSticksSticks
Grid item, default stretchScrolls awayScrolls awayScrolls away
Grid item, align-self: startSticksSticksSticks
Only child of a parent with no extra heightScrolls awayScrolls awayScrolls away

Table headers

We asked each browser what was painted at the top of the window after scrolling.

Whether a table header held its position, by the element made sticky
ElementChromeFirefoxWebKit
position: sticky on thSticksSticksSticks
position: sticky on theadSticksSticksSticks
position: sticky on trSticksSticksSticks

All three painted a sticky row at the top. WebKit has a quirk if you measure one: getBoundingClientRect() on a cell inside a sticky tr returned -200 pixels, where the cell would have been without sticking, while the row itself returned 10 and a screenshot showed it pinned. A script that positions something against those cells should measure the row.

How to fix it

/* Sticky sidebar in a flex or grid row */
.sidebar { position: sticky; top: 1rem; align-self: start; }

/* Stop sideways scroll without a scroll container */
.page { overflow-x: clip; }

/* Page-wide filter that keeps fixed boxes fixed */
html { filter: grayscale(1); }

/* Sticky table header */
th { position: sticky; top: 0; }

When a fixed box scrolls away or sits in the wrong place:

  1. Walk up its ancestors in DevTools and look for the properties in the first table, including will-change.
  2. Remove or move that declaration, or move the box outside that ancestor in the DOM.
  3. For modals and menus, use popover or showModal(), which ignore the ancestors.

When a sticky box won’t stick:

  1. Check that it has an inset on the axis you are scrolling.
  2. Look at every ancestor for overflow other than visible or clip.
  3. Compare the height of the box and its parent. Equal height means no travel.

If the cause still hides, cut the page down with a reduced test case.

How this was tested

A script loaded 182 test pages in each browser on 8 November 2026, in a 1000 by 800 pixel window on Windows 11. It scrolled each page and recorded element positions with getBoundingClientRect() and painted elements with elementFromPoint(). The browsers disagreed on 1 of those cases, the prefixed backdrop filter above.

  • Chrome 156.0.8078.80, headless.
  • Firefox 159.0.1, headless.
  • WebKit 26.0 from Playwright, on Windows.

Most scrolling was done with window.scrollTo(). Four cases were repeated with a real mouse wheel event and gave the same positions in every browser.

Nothing here ran on an iPhone, an iPad or an Android phone, and Playwright’s WebKit is not Safari. Mobile address bars, pinch zoom and on-screen keyboards all change the visible viewport, and none of that was tested. Test fixed headers on a real phone before you trust them there. The full output is available as raw JSON. The test script and this article were prepared with help from AI.

How this used to work

Can I Use lists Safari as needing position: -webkit-sticky before version 13. It also lists Chrome from 56 to 90 supporting sticky on th but not thead or tr, and Firefox before 59 not supporting it on any table part, which made th the safe choice then. In these builds thead and tr worked too. We read that data on 8 November 2026.

Common questions

Why is my fixed header scrolling with the page?

An ancestor has one of the properties in the first table. Look for transform, filter or will-change first, including on body.

Does overflow-x: hidden on body break sticky?

Not on its own, because the browser applies it to the viewport. It broke when html had a non-visible overflow too, which left body as a scroll container that didn’t scroll.

Can a fixed box ignore a transformed parent?

Only from the top layer, using popover or showModal(). Otherwise it has to live outside that parent in the DOM.

Sources