Rendering PipelineMay 15, 2026·8 min read·

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:

naive — red z-index: 10 000 vs blue z-index: 2
<body>
#rednew context
position: relative; z-index: 10000
#bluenew context
position: relative; z-index: 2
#blue
#red
Both boxes are siblings of the document root. z-index orders them directly. Red's huge value wins.
This is the only case where 'bigger z-index wins' is the whole story.

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.

tl;dr

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:

  1. Naive — both elements are direct children of <body>. They share the root stacking context. z-index orders them directly. Red (10 000) wins.
  2. Trapped (opacity: 0.99)#red's parent has opacity: 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 #blue at default level — which loses. Blue wins.
  3. 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.
  4. 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:

The root element <html>
classic
html { /* implicit */ }
Always creates the root stacking context. Everything eventually stacks here.
position: relative | absolute with z-index ≠ auto
classic
position: relative; z-index: 0;
z-index: auto does NOT create a context. z-index: 0 DOES. This is the most common gotcha.
position: fixed | sticky (z-index irrelevant)
classic
position: fixed;
Always creates a stacking context, regardless of z-index.
opacity < 1
classic
opacity: 0.99;
Even opacity: 0.99 creates a new context. Silent footgun — common in fade transitions.
transform: <anything other than 'none'>
modern
transform: translateZ(0);
Including translateZ(0) — the GPU-promotion hack. Every 'will-change: transform' hint creates a context.
filter: <anything other than 'none'>
modern
filter: blur(0);
Includes blur, drop-shadow, grayscale, etc.
backdrop-filter: <anything other than 'none'>
modern
backdrop-filter: blur(10px);
The 'glass morphism' property. Also creates a context.
perspective: <anything other than 'none'>
modern
perspective: 1000px;
3D-transform setup property. Creates a context on the parent.
clip-path: <anything other than 'none'>
modern
clip-path: inset(0);
Shape clipping creates a context.
mask / mask-image / mask-border
modern
mask-image: url(mask.svg);
Any non-default mask creates a context.
isolation: isolate
modern
isolation: isolate;
The ONE property whose only purpose is to create a stacking context. Best tool for component-level scoping.
mix-blend-mode: <anything other than 'normal'>
modern
mix-blend-mode: multiply;
Blend modes also create a new context.
contain: layout | paint | strict | content
modern
contain: layout paint;
CSS containment (see the separate containment post) — any of these values creates a context.
content-visibility: auto | hidden
modern
content-visibility: auto;
Off-screen virtualization shortcut creates a stacking context.
will-change: <any stacking-context-triggering prop>
compositor hint
will-change: transform;
will-change: transform / opacity / filter / etc. creates the context proactively — even when the actual property is at its default.
Flex / grid item with z-index ≠ auto
classic
/* inside a flex container */
z-index: 1;
Flex/grid children opt into the stacking context by setting z-index, even without position.

Three categories worth internalising:

  • Classic — CSS 2.1 Appendix E originals: the root element, and any position: relative | absolute element with z-index ≠ auto. Everything below the next bullet is from later specs layered on top.
  • Post-2.1 additions that silently trigger a contextposition: fixed and position: 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); certain contain values (layout, paint, strict, content); content-visibility: auto | hidden; flex/grid items with z-index ≠ auto. Any one of these silently creates a context — most z-index bugs since 2019 trace to this list.
  • Compositor hintswill-change: transform | opacity | filter | …. The property doesn't have to be active; hinting is enough. A will-change: transform on 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:

  1. 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.
  2. 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.
  3. 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).
  4. If you don't want it, can you remove the trigger? Swap opacity: 0.99 for opacity: 1, drop the will-change if the perf win isn't measurable, use isolation: auto instead of isolate if you added it prematurely.
  5. 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 — and z-index starts 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: 1000 inside your component can't paint above the host's z-index: 2 nav.
  • The host's z-index bumps 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":

  1. Open DevTools, find the element, walk up the ancestors looking for stacking-context triggers.
  2. Is the culprit trigger necessary? If not, remove it.
  3. If it's necessary, move the losing element to a portal (or the top layer via popover).
  4. Add isolation: isolate at your component root to prevent the next one.

Not "bump the number".

Primary sources