CSS z-index & Stacking Context Table
| Pattern | What it does | Field note |
|---|---|---|
z-index: 10 | Stacking order within a context | Only works on positioned or flex/grid children |
position: relative (z-index: auto) | Creates NO context | The surprise: children stack against the PAGE, not this box |
position: relative (z-index: 0) | Creates a context | 0 vs auto is the whole game - 0 fences children in |
opacity < 1 | Creates a context | Any transparency isolates - the dropdown-under-overlay classic |
transform: translateZ(0) | Creates a context | Performance hacks fence stacking too |
isolation: isolate | Creates a context, nothing else | The explicit fix - no side effects |
position: fixed / sticky | Creates a context | Always - explains modals escaping sticky headers |
z-index values do not compete globally - they compete inside the nearest stacking context. That single sentence explains every 'z-index: 9999 did nothing' bug: an element trapped in a context whose own z-index is low can never rise above a sibling context, no matter how big its number. The table below lists the seven patterns that create contexts, each of which fences its children in.
Bottom line: the differences people overlook are the whole game. position: relative with z-index: auto creates NO context (children stack against the page), while the same element with z-index: 0 fences them in. opacity below 1 creates one. transform, filter and will-change do too - which is why performance tweaks silently break dropdowns. The modern clean fix is isolation: isolate on widget roots, which creates a context with zero side effects.
The honest part: the classic debugging sequence still wins over memorization. Find the common ancestor of the two clashing elements, walk down the tree, and check which wrapper creates a context - DevTools' Elements panel shows stacking contexts with a badge in recent versions. Then either remove the accidental context or add isolation: isolate to the widget root that SHOULD fence its children. Modal z-index lotteries end when widget roots stop being transparent to the page's stacking.
How to use
- Identify the two clashing elements and their common ancestor - that ancestor's subtree is where the war is fought.
- Walk down checking the seven context-creators in the table; the accidental one (usually opacity or transform on a wrapper) is the fence.
- Fix at the widget root: isolation: isolate for an intentional fence, removing the accidental context for an unintentional one - then modest z-index values work again.
Frequently asked questions
Why does z-index: 9999 not work on my dropdown?
Because the dropdown lives inside a stacking context whose own z-index is lower than a sibling context's - 9999 competes only within its own context, never against the outside world. The fence is usually an accidental one: a wrapper with opacity, transform, filter or will-change, or position: fixed. Walk up from the dropdown to find the context boundary, then either remove that property or give the dropdown's own root a z-index that competes at the right level.
What is the difference between z-index: auto and z-index: 0?
z-index: auto creates no stacking context (the element is stacking-transparent), while z-index: 0 creates one with an explicit level of zero. Visually identical for the element itself - radically different for its children: with auto, a child's z-index: 9999 competes against the whole page; with 0, the child's 9999 is trapped inside. Neither is 'correct' - auto is the default behavior you usually want, and 0/isolation: isolate is the deliberate fence.
Which CSS properties secretly create stacking contexts?
The usual suspects: opacity below 1, transform, filter, backdrop-filter, will-change, contain: paint, position: fixed or sticky, isolation: isolate, and elements inside flex or grid containers with z-index set. The trap is that several are performance optimizations - adding transform: translateZ(0) to fix jank silently fences a dropdown. The table's seven patterns plus a DevTools pass (contexts show a badge in the Elements panel) catch them all.
How do I stop debugging z-index lotteries permanently?
Adopt the widget-root convention: every independently-positioned widget (dropdown, modal, tooltip, toast) gets a root element with isolation: isolate and a documented z-index tier (dropdowns 100, overlays 200, modals 300, toasts 400). Inside a widget, children fight only among themselves. Document the tiers in one place, and new z-index values stop being guesses - the lottery ends because every ticket is sold from the same booth.