z-index: 10 000 does nothing — stacking contexts, explained properly
Every senior FE dev has bumped a z-index until it 'worked'. Nobody should need to. The rule is simple: z-index only orders siblings within the same stacking context, and 16+ CSS properties create one silently. Here's the spec, four scenarios, and the complete trigger list.
Four DOM trees that look almost identical. Click through and watch which box wins:
There's a bug every frontend engineer ships at least once a year. The symptom: a modal, tooltip, or sticky nav paints under something it obviously shouldn't. The fix attempted: bump z-index to a bigger number. Sometimes it works. Sometimes z-index: 99999 doesn't help, and the dev gives up and restructures the DOM.
The rule nobody learns until they've shipped the bug three times: z-index only orders siblings within the same stacking context. A z-index: 10 000 on an element whose parent created a new stacking context can never paint above an element outside that parent. No matter how big the number is. And the CSS spec defines many properties that create a new stacking context — most of them surprising.
This post walks the spec, shows the four DOM configurations that produce different paint orders from "the same code", and ends with a complete list of every property that opens a new context.
A stacking context is a local ordering scope. z-index orders only within the current stacking context; it cannot cross into a parent or sibling context. A new stacking context is created by ~16 CSS properties: classic ones (position: relative/absolute + z-index, position: fixed, opacity < 1) and modern ones (transform, filter, isolation: isolate, will-change: transform, contain, content-visibility, etc.). Debugging z-index is 10% about the number and 90% about finding where a parent silently opened a new context. isolation: isolate is the only property whose sole job is to open one deliberately.
What the spec actually says
The authoritative definition lives in CSS 2.1 (Appendix E), and CSS Positioned Layout Level 3 §2.2 refers back to it for the painting-order contract:
Stacking contexts can contain further stacking contexts. A stacking context is atomic from the perspective of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.
Two sentences, one consequential idea. A stacking context is atomic: once a parent creates one, nothing inside it can leak out into the parent's ordering, and nothing from outside can slip in. You can think of it as a sealed sub-bucket. The outer world sees the bucket's position in its parent's order, not the positions of the boxes inside the bucket.
This is why z-index: 10 000 inside an opacity: 0.99 parent loses to z-index: 2 on a sibling of the parent. The 10 000 is inside a sealed bucket; the parent and the sibling are unrelated peers in the outer context, and the sibling's z-index wins that local comparison.
The four scenarios
The canvas at the top walks four variants. The takeaways:
- Naive — both elements are direct children of
<body>. They share the root stacking context.z-indexorders them directly. Red (10 000) wins. - Trapped (
opacity: 0.99) —#red's parent hasopacity: 0.99. That's a stacking-context trigger (spec calls it out explicitly).#red's 10 000 is scoped inside the parent's bubble. The parent itself stacks against#blueat default level — which loses. Blue wins. - Trapped (
transform: translateZ(0)) — same failure mode, but the trigger is the GPU-promotion hack. Anyone who has shipped a scroll-perf optimization has shipped this bug. Blue wins. - Intentional (
isolation: isolate) — same trap, but on purpose. The parent scopes its descendants' z-indexes so a misbehaving widget can't punch through to the layout's dropdown menu. Blue wins.
The difference between scenarios 2, 3, 4 is only intent. The browser does the same thing in all three.
Every way to open a new stacking context
Every z-index bug ends at "which ancestor opened a new stacking context?" Here's the complete list, filterable by origin:
html { /* implicit */ }position: relative; z-index: 0;position: fixed;opacity: 0.99;transform: translateZ(0);filter: blur(0);backdrop-filter: blur(10px);perspective: 1000px;clip-path: inset(0);mask-image: url(mask.svg);isolation: isolate;mix-blend-mode: multiply;contain: layout paint;content-visibility: auto;will-change: transform;/* inside a flex container */
z-index: 1;Three categories worth internalising:
- Classic — CSS 2.1 Appendix E originals: the root element, and any
position: relative | absoluteelement withz-index ≠ auto. Everything below the next bullet is from later specs layered on top. - Post-2.1 additions that silently trigger a context —
position: fixedandposition: sticky(CSS Positioned Layout L3);opacity < 1(CSS Color);transform(any non-initial value),filter,backdrop-filter,clip-path,mask,perspective,mix-blend-mode(CSS Transforms / Filters / Compositing specs);isolation: isolate(CSS Compositing); certaincontainvalues (layout,paint,strict,content);content-visibility: auto | hidden; flex/grid items withz-index ≠ auto. Any one of these silently creates a context — most z-index bugs since 2019 trace to this list. - Compositor hints —
will-change: transform | opacity | filter | …. The property doesn't have to be active; hinting is enough. Awill-change: transformon a button for hover responsiveness creates a context the dev never intended.
Debugging a z-index bug
When z-index isn't doing what you expect, the ladder is:
- Find the element in DevTools and walk up its ancestors. Neither Chrome nor Firefox ships a built-in stacking-context inspector. The CSS Stacking Context Inspector Chrome extension is the cleanest power-tool — it adds a DevTools panel that renders the full stacking-context tree. Without it, walk the ancestor chain manually and check each parent's Computed tab for any of the triggers above.
- Walk up the ancestors of the element that's losing. For each ancestor, check every property in the list above. The first one that applies is your culprit.
- Decide: did you want that ancestor to open a context? Often yes (a modal backdrop; a will-change-ed carousel item). If yes, accept the scoping and move your modal / tooltip out of that subtree (into a portal).
- If you don't want it, can you remove the trigger? Swap
opacity: 0.99foropacity: 1, drop thewill-changeif the perf win isn't measurable, useisolation: autoinstead ofisolateif you added it prematurely. - If the trigger is necessary, a React portal (or plain
document.body.appendChild) renders your overlay as a sibling of<body>— outside the offending context — andz-indexstarts behaving.
isolation: isolate is a scoping primitive, not a hack
The opposite problem: you're shipping a component and you want its internal z-indexes (dropdowns, tooltips, focus rings) to stack nicely without leaking into the host page's z-index namespace. Add isolation: isolate to your component root.
- Internal
z-index: 1000inside your component can't paint above the host'sz-index: 2nav. - The host's
z-indexbumps can't punch through your component. - Nothing else changes — no backdrop-filter, no blur, no perf cost. It's the only property whose only effect is stacking-context creation.
This is the pattern every component library should adopt at its root. It turns z-index from a global coordination problem into a component-local ordering problem.
Portals are the other side of the same coin
Any time you need an element to escape a stacking context — modals, tooltips, dropdowns, floating menus — the right move is a React portal (or a vanilla document.body.appendChild). The portal renders the element outside the current subtree, so it inherits the root stacking context. No amount of z-index fiddling can replicate this: being outside the subtree is the whole point.
This is also why the HTML Popover API (and the longer-standing <dialog>) puts open popovers/dialogs into the top layer — a spec-level z-axis above the entire document's stacking-context tree. When a <div popover> is opened, it's painted above everything, no z-index required. That's how the platform finally solved the "modal lost to a transform-ed parent" problem without portals.
The checklist
When someone asks "why is my z-index broken":
- Open DevTools, find the element, walk up the ancestors looking for stacking-context triggers.
- Is the culprit trigger necessary? If not, remove it.
- If it's necessary, move the losing element to a portal (or the top layer via
popover). - Add
isolation: isolateat your component root to prevent the next one.
Not "bump the number".
Primary sources
- CSS 2.1 — Appendix E: Elaborate description of Stacking Contexts — the normative definition (referenced by later specs).
- CSS Positioned Layout Level 3 — §2.2 Painting Order and Stacking Contexts — modern wrapper that points back to CSS 2.1.
- CSS Color Module Level 4 — Transparency —
opacity < 1creating a stacking context is spec'd here. - CSS Transforms Module Level 1 — Stacking context effect — every non-default transform creates a context.
- CSS Containment Module Level 3 — contain, isolation, content-visibility — the modern cluster.
- MDN — The stacking context — a curated list plus the canonical diagrams.
- HTML Living Standard — The top layer — how
popoverand<dialog>bypass the stacking-context tree entirely.