The stacking context — z-index gotchas explained
"Just raise the z-index" fails a huge share of the time because z-index doesn't compare globally across the whole page — it only compares siblings within the same stacking context, and a surprising number of ordinary CSS properties silently create a new one.
3 min read
Why z-index: 9999 sometimes still loses to z-index: 1
<div style="position: relative; z-index: 1;">
<div style="position: absolute; z-index: 9999;">I'm behind the other box!</div>
</div>
<div style="position: relative; z-index: 2;">
<div>I'm in front, despite no z-index at all</div>
</div>The z-index: 9999 element loses here because z-index values are only ever compared within the same stacking context — and the first outer div (with z-index: 1) creates its own stacking context, trapping the 9999 inside it. Once trapped, that 9999 is compared against the outer elements' z-indices (1 vs 2), not against anything inside the second outer div at all — the inner 9999 never gets a chance to compete globally, because stacking contexts nest like sealed boxes, not a single flat list.
What actually creates a new stacking context (it's more than just z-index)
.a { position: relative; z-index: 1; } /* creates one — position + a z-index value */
.b { opacity: 0.99; } /* creates one — ANY opacity below 1, no z-index needed */
.c { transform: translateX(1px); } /* creates one — any transform at all */
.d { filter: blur(0px); } /* creates one */
.e { will-change: transform; } /* creates one, even before any actual transform happens */This is the real, common surprise: stacking contexts aren't only created by position + z-index — opacity less than 1, any transform, filter, will-change, and several other properties each independently create a new one, entirely regardless of whether z-index is even set. This is exactly why adding an animation (transform, commonly) to an element can suddenly break its stacking relationship with a dropdown or tooltip elsewhere on the page — a change that looks completely unrelated to layering can silently create a new sealed box that traps or excludes other elements' z-index comparisons.
Debugging a "z-index isn't working" bug: walk up the ancestor chain
For an element whose z-index seems ignored: walk UP its ancestor chain
and check each one for position+z-index, opacity<1, transform, filter,
or will-change — the first ancestor found that creates a stacking context
is the "sealed box" the element's z-index is actually being compared
INSIDE, and the fix usually means moving the z-index (or removing the
context-creating property) higher up that chain, not raising the number.
Because the actual scope a z-index competes within is determined by the nearest ancestor stacking context, the fix for "it's not working even at 99999" is almost never "raise the number further" — a trapped z-index stays trapped regardless of magnitude. The real fix is finding which ancestor is creating the unwanted boundary and either removing the property causing it, or moving the z-index competition up to that same level instead.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why can `z-index: 9999` still lose to `z-index: 1` elsewhere on the page?
2. Besides `position` + `z-index`, what else creates a new stacking context?
3. What's the actual fix for a z-index that 'isn't working' even at a very high value?