skip to content

In CSS, an overlay stopped painting above the page after an ancestor got a transform. Which common declarations create a stacking context even with no z-index set?

level: seniorimportance: should knowfreq 55%

answer

  1. not just position and z-index
  2. anything that composites the element
  3. opacity, transform, filter, will-change
  4. overflow is clipping, not stacking
  5. isolation: isolate is the deliberate one

basics

~20 s

Many declarations create a stacking context on their own: opacity below 1, any transform, filter or backdrop-filter, perspective, clip-path, mask, mix-blend-mode other than normal, isolation: isolate, contain values including paint, and will-change naming any of these. position: fixed does too.

solid answer

~50 s

Positioning with a z-index is only one entry on a long list. A stacking context is also created by `opacity` less than 1, any `transform` or `perspective`, `filter` and `backdrop-filter`, `clip-path`, `mask`, `mix-blend-mode` other than `normal`, `isolation: isolate`, `contain` values that include paint or layout, `content-visibility: auto`, and `will-change` naming any property that would create one. `position: fixed` creates one unconditionally, and so does the root `<html>` element. This is why the bug in the question happens so often: someone adds a fade, a `translateY` slide, or a `will-change` hint for smoothness on a wrapper, and every positioned descendant is silently confined to that wrapper's level. Nothing about the declaration looks stacking-related, which is exactly what makes it hard to find — the debugging move is to walk the ancestor chain and check the computed styles, not to raise numbers.

code

css · 13 lines
css
/* creates a stacking context */
.a { opacity: 0.99; }
.b { transform: translateZ(0); }
.c { filter: blur(0); }
.d { mix-blend-mode: multiply; }
.e { will-change: opacity; }
.f { contain: paint; }
.g { isolation: isolate; }

/* does NOT create a stacking context */
.h { overflow: hidden; }
.i { position: relative; z-index: auto; }
.j { display: flex; }

go deeper

for a junior

Learn that ordinary visual declarations such as opacity and transform can change stacking. If an overlay hides after a style change, suspect the ancestor that changed, not the overlay.

for a middle

Recite the main creators — opacity below 1, transform, filter, mix-blend-mode, isolation, contain, will-change, fixed and sticky — and explain that overflow clips rather than stacks.

for a senior

Demonstrate the diagnosis: walk the ancestor chain in devtools, read computed styles, and separate a clipping symptom from a stacking symptom before choosing a fix. Call out will-change and mid-transition opacity as the usual culprits.

for a principal

Set the guardrail — components declare isolation: isolate at their root and will-change is scoped to the animation's lifetime — so this class of bug stops recurring instead of being rediagnosed each time.

## Why this list matters Almost every hard z-index bug is really this bug: a stacking context appeared where nobody meant to create one. The element that broke is often untouched — the regression is in an ancestor, in a declaration that reads as purely visual. Knowing the list turns a two-hour hunt into a two-minute one. ## The declarations that create a stacking context Beyond the root element and positioned elements with a non-`auto` `z-index`, a stacking context is created by: - `position: fixed` — **unconditionally**, with or without a z-index. - `position: sticky` — likewise, whenever the element is a sticky-positioned box. - `opacity` less than `1` — including `0.999`, the old "nudge it onto its own layer" trick. - `transform` other than `none` — including the identity-ish `translateZ(0)` and `scale(1)`. - `filter` other than `none`, and `backdrop-filter` other than `none`. - `perspective` other than `none`. - `clip-path` other than `none`. - `mask`, `mask-image`, or `mask-border` other than `none`. - `mix-blend-mode` other than `normal`. - `isolation: isolate` — the only one on this list whose *entire purpose* is to create a context. - `contain: layout`, `contain: paint`, `contain: strict`, `contain: content`. - `content-visibility: auto`. - `will-change` naming any property that would create a context if it had a non-initial value — `will-change: opacity` and `will-change: transform` are the everyday offenders. - Flex and grid items with a `z-index` other than `auto`. ```css /* every one of these seals its descendants */ .fade { opacity: 0.98; } .slide { transform: translateY(0); } .smooth { will-change: transform; } .crop { clip-path: inset(0); } .safe { isolation: isolate; } ``` ## The `will-change` trap specifically `will-change` is meant as a hint, and people scatter it on wrappers to make an animation feel smoother. But `will-change: transform` creates a stacking context *immediately and permanently*, whether or not the element is currently animating. A wrapper that carries it forever confines every positioned descendant forever. If a hint is genuinely needed, add it just before the animation and remove it after — leaving it on as a blanket "performance" measure is where this class of bug is born. ## The `opacity` trap The subtlety with `opacity` is that the threshold is *any* value below 1, not a visible one. `opacity: 0.999` looks identical to fully opaque and behaves completely differently for stacking. Fade animations that end at `opacity: 1` are fine at rest but create a context for every intermediate frame — so an overlay can be trapped only *during* the transition, producing a flicker that is maddening to reproduce. ## What does *not* create one Just as useful as the list is knowing the near-misses that people wrongly add to it: - `overflow: hidden` / `auto` / `scroll` — creates a **block formatting context** and can clip a descendant, but it does **not** create a stacking context. If your overlay is cut off rather than layered wrongly, this is the culprit, and the diagnosis is different. - `position: relative` with `z-index: auto` — positioned, but no context. - `display: flex` or `display: grid` on the *container* — the container itself gets nothing; only its *items* with a non-auto z-index do. - `float`, `display: block`, plain colours and spacing — nothing. Confusing clipping with stacking sends people down the wrong path, so keep the two symptoms separate: *hidden behind* means stacking, *cut off at an edge* means overflow. ## The debugging procedure 1. Select the misbehaving element in devtools. 2. Walk **up** the ancestor chain. Chrome and Firefox both badge elements that establish a stacking context in the Elements/Inspector panel, so the offender is usually visible at a glance; Firefox's inspector can also highlight them explicitly. 3. At the first such ancestor, read its computed style for the properties above. One of them will be set — often by a utility class or an animation library rather than by the component's own CSS. 4. Decide: remove it if it was incidental, raise the ancestor's z-index if the context is legitimate, or relocate the overlay outside the subtree. ## Turning the trap into a tool The same mechanism, used deliberately, is the cure. `isolation: isolate` creates a stacking context and does nothing else — no compositing side effects, no opacity change, no transform. Putting it on a component root guarantees the component's internal z-index values stay internal and cannot leak into a page-wide arms race. It is the one item on this list you should reach for on purpose.

  • How would you distinguish an overlay that is stacked wrongly from one that is being clipped?
    Look at the symptom shape. Stacking failures hide the element *behind* another painted box, and it reappears if the covering element is hidden. Clipping cuts the element off along a rectangular edge that matches an ancestor's box, and it never appears outside that box. Clipping points at `overflow` on an ancestor; stacking points at a stacking context.
  • Why is leaving will-change: transform on a wrapper permanently a bad idea?
    It creates a stacking context — and usually a compositor layer — for the element's whole lifetime, not just while it animates. That confines every positioned descendant permanently and costs memory for a hint the browser no longer needs. Add it right before the animation starts and remove it when the animation ends.
  • An overlay works at rest but is hidden mid-transition. What explains that?
    The transition is passing through a value that creates a stacking context. A fade animating from `opacity: 0` to `1` creates one for every frame below 1 and drops it exactly at 1, so the descendant is trapped only during the animation. The same happens with a transform that ends at `none`.

saying these in an interview costs you the question

  • Believes only position plus z-index creates a stacking context
  • Claims overflow: hidden creates a stacking context
  • Sprinkles will-change on wrappers as a permanent optimisation
  • Assumes opacity: 0.999 is stacking-equivalent to opacity: 1
  • Thinks display: flex on the container creates a context

context