z-index: 9999 doesn't work, and stacking contexts are why
Every developer has done it: a modal renders behind the header, so you set
z-index: 9999, and it still renders behind the header. Bumping it to 99999
does nothing. The instinct — a bigger number should win — is wrong because
z-index does not compare globally. It only orders elements within the same
stacking context, and the reason your modal loses is that some ancestor quietly
created a new stacking context, sealing your element’s z-index inside it. Once
you understand stacking contexts, the fix stops being a number war and becomes
“find the context boundary and move the element out of it.”
z-index is local to a stacking context
A stacking context is a self-contained z-ordering world. Elements inside it are
ordered by their z-index relative to each other, but the context as a whole is
placed among its siblings by its own z-index. So a z-index: 9999 element inside
a context whose root has z-index: 1 can never rise above a sibling context with
z-index: 2 — the 9999 is a rank within the 1, not a global rank. That is the
entire mystery:
.header { position: relative; z-index: 10; } /* header context, rank 10 */
.card { position: relative; z-index: 1; } /* card context, rank 1 */
.card .modal { z-index: 9999; } /* rank 9999 INSIDE a context that ranks 1 */
/* result: modal sits below the header, because 1 < 10, and 9999 never escapes the 1 */
What silently creates a context
The trap is that many innocuous properties create a stacking context, not just
z-index. A transform, an opacity below 1, a filter, will-change, a
position: fixed, and several others each start a new context — so a card with a
subtle transform: scale() on hover has, without anyone deciding to, sealed its
children’s z-index:
/* each of these quietly starts a new stacking context */
.card { transform: translateZ(0); } /* or opacity: 0.99, filter: blur(0), etc. */
This is why “it worked, then I added a hover animation and the dropdown broke” is such a common bug — the animation created the context.
Fix it by escaping the context, not raising the number
Since the problem is containment, the fix is to get the overlay out of the
low-ranking context, not to inflate its z-index. Two durable moves: render the
overlay via a portal at the end of <body>, so it lives in the root context
and competes globally; or restructure so the overlay is not nested under an element
that starts a context. And to keep this from recurring, stop using arbitrary big
numbers — define a small set of named layer tokens (--z-dropdown: 100;
--z-modal: 200; --z-toast: 300;) so z-index becomes a designed, comparable scale
instead of an escalating guess. The 9999 habit is a symptom of not knowing where
the context boundaries are; once you can see them, ordering becomes deliberate. The
accessible-combobox exercise runs straight into this — a listbox that must escape
its input’s stacking context to render on top — which is where the portal-plus-token
approach proves itself over a bigger number.