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.
| CSS on the ancestor | Chrome | Firefox | WebKit |
|---|---|---|---|
| transform: translateX(0) | Ancestor | Ancestor | Ancestor |
| transform: translateZ(0) | Ancestor | Ancestor | Ancestor |
| translate: 0 | Ancestor | Ancestor | Ancestor |
| rotate: 0deg | Ancestor | Ancestor | Ancestor |
| scale: 1 | Ancestor | Ancestor | Ancestor |
| transform-style: preserve-3d | Ancestor | Ancestor | Ancestor |
| perspective: 100px | Ancestor | Ancestor | Ancestor |
| filter: blur(0) | Ancestor | Ancestor | Ancestor |
| backdrop-filter: blur(2px) | Ancestor | Ancestor | Ancestor |
| -webkit-backdrop-filter: blur(2px) | Viewport | Viewport | Ancestor |
| contain: layout | Ancestor | Ancestor | Ancestor |
| contain: paint | Ancestor | Ancestor | Ancestor |
| contain: content | Ancestor | Ancestor | Ancestor |
| contain: strict | Ancestor | Ancestor | Ancestor |
| content-visibility: auto | Ancestor | Ancestor | Ancestor |
| will-change: transform | Ancestor | Ancestor | Ancestor |
| will-change: perspective | Ancestor | Ancestor | Ancestor |
| will-change: filter | Ancestor | Ancestor | Ancestor |
| will-change: backdrop-filter | Ancestor | Ancestor | Ancestor |
| will-change: contain | Ancestor | Ancestor | Ancestor |
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
| CSS on the ancestor | Chrome | Firefox | WebKit |
|---|---|---|---|
| transform: none | Viewport | Viewport | Viewport |
| filter: none | Viewport | Viewport | Viewport |
| backdrop-filter: none | Viewport | Viewport | Viewport |
| perspective: none | Viewport | Viewport | Viewport |
| contain: size | Viewport | Viewport | Viewport |
| contain: style | Viewport | Viewport | Viewport |
| contain: inline-size | Viewport | Viewport | Viewport |
| container-type: inline-size | Viewport | Viewport | Viewport |
| container-type: size | Viewport | Viewport | Viewport |
| will-change: opacity | Viewport | Viewport | Viewport |
| will-change: top | Viewport | Viewport | Viewport |
| overflow: hidden | Viewport | Viewport | Viewport |
| opacity: 0.5 | Viewport | Viewport | Viewport |
| clip-path: inset(0) | Viewport | Viewport | Viewport |
| position: relative | Viewport | Viewport | Viewport |
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.
| CSS | On html | On body |
|---|---|---|
| transform: translateX(0) | Ancestor | Ancestor |
| perspective: 100px | Ancestor | Ancestor |
| filter: blur(0) | Viewport | Ancestor |
| backdrop-filter: blur(2px) | Viewport | Ancestor |
| contain: paint | Ancestor | Ancestor |
| will-change: transform | Ancestor | Ancestor |
| will-change: filter | Viewport | Ancestor |
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.
| CSS on the ancestor | All three browsers |
|---|---|
| overflow: hidden | Visible |
| overflow: clip | Visible |
| position: relative; overflow: hidden | Visible |
| transform: translateX(0); overflow: hidden | Clipped |
| filter: blur(0); overflow: hidden | Clipped |
| will-change: transform; overflow: hidden | Clipped |
| clip-path: inset(0) | Clipped |
| contain: paint | Clipped |
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.
| Element, how it opened | transform: translateX(0) | filter: blur(0) | contain: paint |
|---|---|---|---|
| popover, showPopover() | Viewport | Viewport | Viewport |
| dialog, showModal() | Viewport | Viewport | Viewport |
| dialog, show() | Ancestor | Ancestor | Ancestor |
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.
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.
| Inset | Chrome | Firefox | WebKit |
|---|---|---|---|
| top: 10px | Sticks | Sticks | Sticks |
| top: 0 | Sticks | Sticks | Sticks |
| top: 5% | Sticks | Sticks | Sticks |
| top: -20px | Sticks | Sticks | Sticks |
| inset: 10px | Sticks | Sticks | Sticks |
| inset-block-start: 10px | Sticks | Sticks | Sticks |
| No inset at all | Scrolls away | Scrolls away | Scrolls away |
| left: 10px only | Scrolls away | Scrolls away | Scrolls away |
| right: 10px only | Scrolls away | Scrolls away | Scrolls 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.
| CSS on the grandparent | Chrome | Firefox | WebKit |
|---|---|---|---|
| overflow: visible | Sticks | Sticks | Sticks |
| overflow: hidden | Scrolls away | Scrolls away | Scrolls away |
| overflow: auto | Scrolls away | Scrolls away | Scrolls away |
| overflow: scroll | Scrolls away | Scrolls away | Scrolls away |
| overflow-x: hidden | Scrolls away | Scrolls away | Scrolls away |
| overflow-y: hidden | Scrolls away | Scrolls away | Scrolls away |
| overflow-x: auto | Scrolls away | Scrolls away | Scrolls away |
| overflow: clip | Sticks | Sticks | Sticks |
| overflow-x: clip | Sticks | Sticks | Sticks |
| overflow-y: clip | Sticks | Sticks | Sticks |
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
| CSS | Chrome | Firefox | WebKit |
|---|---|---|---|
| body overflow-x: hidden | Sticks | Sticks | Sticks |
| html overflow-x: hidden | Sticks | Sticks | Sticks |
| html and body overflow-x: hidden | Scrolls away | Scrolls away | Scrolls away |
| html clip, body hidden | Scrolls away | Scrolls away | Scrolls away |
| html hidden, body clip | Sticks | Sticks | Sticks |
| html and body overflow-x: clip | Sticks | Sticks | Sticks |
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.
| Layout | Chrome | Firefox | WebKit |
|---|---|---|---|
| Flex item, default stretch | Scrolls away | Scrolls away | Scrolls away |
| Flex item, align-self: flex-start | Sticks | Sticks | Sticks |
| Flex container, align-items: flex-start | Sticks | Sticks | Sticks |
| Grid item, default stretch | Scrolls away | Scrolls away | Scrolls away |
| Grid item, align-self: start | Sticks | Sticks | Sticks |
| Only child of a parent with no extra height | Scrolls away | Scrolls away | Scrolls away |
Table headers
We asked each browser what was painted at the top of the window after scrolling.
| Element | Chrome | Firefox | WebKit |
|---|---|---|---|
| position: sticky on th | Sticks | Sticks | Sticks |
| position: sticky on thead | Sticks | Sticks | Sticks |
| position: sticky on tr | Sticks | Sticks | Sticks |
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:
- Walk up its ancestors in DevTools and look for the properties in the first table, including
will-change. - Remove or move that declaration, or move the box outside that ancestor in the DOM.
- For modals and menus, use
popoverorshowModal(), which ignore the ancestors.
When a sticky box won’t stick:
- Check that it has an inset on the axis you are scrolling.
- Look at every ancestor for
overflowother thanvisibleorclip. - 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
- CSS Positioned Layout Level 3: sticky positioning
- CSS Transforms Level 1: containing block for all descendants
- CSS Transforms Level 2
- Filter Effects Level 1: the filter property
- CSS Containment Level 2: layout containment
- CSS Will Change Level 1
- CSS Overflow Level 3: overflow viewport propagation
- CSS Positioned Layout Level 4: the top layer
- CSS position: sticky, Can I Use
