A modal styled `position: fixed; inset: 0` covers only part of the page and scrolls away with the content instead of staying pinned to the viewport. Its ancestor card declares `transform: translateZ(0)`. Why does the transform break the modal?
answer
- overlay anchored to the wrong box
- check what the ancestors declare
- transform changes the containing block
- filter, perspective, will-change, contain too
- fix by escaping the subtree
basics
~20 sA transformed ancestor becomes the containing block for fixed-position descendants, so the modal is laid out against that card's padding box rather than the viewport. Move the overlay out of the transformed subtree, or drop the transform.
solid answer
~40 s`position: fixed` normally resolves against the viewport, which is why `inset: 0` fills the screen and the box ignores scrolling. That only holds when no ancestor captures it. An ancestor with `transform` other than `none` — `translateZ(0)` counts — establishes a containing block for both absolutely and fixed positioned descendants, so the modal now sizes and offsets against that card's padding box and scrolls with it. The same capture happens for `perspective`, `filter`, `backdrop-filter`, `will-change` naming one of those properties, `contain: layout|paint|content|strict`, and `container-type` other than `normal`. The fix is structural rather than cosmetic: render the overlay in a container at the end of `<body>`, outside the transformed subtree, or use a top-layer element so the viewport is the containing block again. Raising `z-index` changes nothing here.
code
css · 15 lines.card {
transform: translateZ(0);
}
.card .modal {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
}
.overlay-root .modal {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
}go deeper
Know that position: fixed is normally measured from the viewport, and that when a fixed overlay behaves strangely the first thing to inspect is its ancestors, not its own rules.
Be able to name the containing-block rule precisely: a transform, filter, perspective, will-change, contain, or container-type on an ancestor makes that ancestor the containing block for fixed descendants, resolved against its padding box.
Show how you would find this in a real codebase — walking computed styles up the tree, spotting animation-only transforms — and argue for an overlay root or the top layer instead of a per-component patch.
Own the guardrail: decide where overlays mount across the whole app, keep compositing hacks out of shared containers, and treat any component that mounts a fixed layer inside arbitrary parent markup as a design defect.
## What `position: fixed` is supposed to do A box with `position: fixed` is removed from normal flow and placed against its **containing block** — the rectangle its `top` / `right` / `bottom` / `left` offsets (or the `inset` shorthand) are measured from. For a fixed box that containing block is normally the **viewport**, so `inset: 0` with no explicit width or height stretches it edge to edge, and the box does not move when the page scrolls. That combination is the standard recipe for a full-screen modal scrim. ```css .modal { position: fixed; inset: 0; /* top/right/bottom/left: 0 */ background: rgb(0 0 0 / 0.5); } ``` ## The exception The viewport is the containing block only while no ancestor captures fixed descendants. Several properties change that. If any ancestor has: - `transform` with a computed value other than `none` — including no-op values such as `translateZ(0)`, `scale(1)`, and `translate: 0`; - `perspective` other than `none`; - `filter` or `backdrop-filter` other than `none` — again including a no-op such as `blur(0px)`; - `will-change` listing one of the properties above; - `contain: layout`, `paint`, `content`, or `strict`; - `container-type` other than `normal` (which applies layout containment); …then that ancestor becomes the containing block for **fixed** descendants, and for absolutely positioned ones too. The offsets are then resolved against that ancestor's **padding box**. Practical consequences: `inset: 0` sizes the overlay to the card, not the screen; the overlay scrolls with the page because it is anchored to a scrolling element; and it can be clipped if that ancestor also clips overflow. This is why the bug appears so often in component libraries. `transform: translateZ(0)` is a habitual compositing hack, hover animations use `transform: scale(1.02)`, card shadows are sometimes done with `filter: drop-shadow(...)`, and `container-type: inline-size` is added for container queries. Any of them silently converts a subtree into a trap for fixed overlays. ## How to confirm it Walk up the DOM from the overlay in DevTools and look at the computed values of `transform`, `filter`, `backdrop-filter`, `perspective`, `will-change`, `contain`, and `container-type` on every ancestor. A quick sanity test is to temporarily set `transform: none` on the suspect ancestor; if the modal snaps to the viewport, you have found it. Note that the transform does not have to be visible — an animation that lands on `transform: none` at rest can still leave a non-`none` computed value while a transition or `will-change` is active, which produces the maddening intermittent version of the bug. ## Fixes, best first **Render the overlay outside the subtree.** Put a dedicated overlay root at the end of `<body>` and mount modals, dropdowns, and toasts there. Nothing in the page's card markup can then capture them, and the same move also escapes ancestor clipping and ancestor painting order. This is the classic "portal" pattern; the CSS half of it is simply that the overlay's ancestors are now just `<body>` and `<html>`. **Use the top layer.** An element promoted to the browser's top layer is laid out against the viewport regardless of where it sits in the DOM, so the transformed ancestor stops mattering without moving any markup. **Remove or narrow the transform.** If the transform exists only as a compositing hack, delete it. If it is real (a hover scale), scope it to the smallest element possible so overlays are not inside it. **What does not work:** increasing `z-index`, adding `!important`, switching to `position: absolute` (transform captures those too), or setting `overflow: visible` on the ancestor. Those address different problems and leave the containing block exactly where it was. ## Why the rule exists A transform sets up a new coordinate system for its subtree. If a descendant were still positioned against the viewport, the browser would have to render it in one coordinate space while it lives inside another — and a transformed ancestor can be rotated, scaled, or 3D-projected, so there is no single sensible answer. Making the transformed element the containing block keeps the subtree internally consistent. Containment (`contain`, `container-type`) captures descendants for the same reason: the promise of containment is that nothing inside can affect layout outside, and a viewport-anchored descendant would break that promise.
- Does this affect `position: absolute` descendants as well, or only fixed ones?Both. A transformed, filtered, or contained ancestor becomes the containing block for absolutely positioned descendants too. The difference is that absolute boxes usually already resolve against some positioned ancestor, so the change is less visible; a fixed box goes from the viewport to a scrolling element, which is dramatic.
- A hover animation on a card only breaks the modal while the mouse is over it. How is that possible?During the transition the card's computed `transform` is a real matrix rather than `none`, so it captures fixed descendants for exactly as long as the animation runs. A lingering `will-change: transform` produces the same effect permanently. Diagnose by checking computed values while the state is active, not at rest.
- Why does raising `z-index` never fix this particular bug?`z-index` affects paint order, not layout. The modal here is painted perfectly well — it is simply being measured against the card's padding box instead of the viewport. Its size and offsets are wrong before painting is even considered, so any `z-index` value leaves it exactly where it is.
A transform is like putting the card in its own picture frame: everything inside is now measured from the frame's edges, so "pin this to the wall" quietly becomes "pin this to the frame".
saying these in an interview costs you the question
- Claims a big enough z-index will fix it
- Thinks only overflow: hidden can affect fixed elements
- Believes translateZ(0) is a harmless performance hint
- Switches to position: absolute expecting to escape
- Blames the browser rather than checking ancestors