CSS z-index & Stacking Context Table

PatternWhat it doesField note
z-index: 10Stacking order within a contextOnly works on positioned or flex/grid children
position: relative (z-index: auto)Creates NO contextThe surprise: children stack against the PAGE, not this box
position: relative (z-index: 0)Creates a context0 vs auto is the whole game - 0 fences children in
opacity < 1Creates a contextAny transparency isolates - the dropdown-under-overlay classic
transform: translateZ(0)Creates a contextPerformance hacks fence stacking too
isolation: isolateCreates a context, nothing elseThe explicit fix - no side effects
position: fixed / stickyCreates a contextAlways - explains modals escaping sticky headers
Reference: the MDN z-index reference. z-index values do NOT compete globally - they compete inside the nearest stacking context, so z-index: 9999 inside a context with z-index: 0 can never escape above the neighboring context with z-index: 1. The debugging sequence: find the common ancestor of the clashing elements, then walk down checking which wrapper creates a context (opacity, transform, filter, will-change, fixed/sticky). Bottom line: set z-index: 0 or isolation: isolate on every widget root, and modal z-indexes stop lottery-ing. Related tools: CSS display table, box model table, and media queries table for the responsive dimension.

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

  1. Identify the two clashing elements and their common ancestor - that ancestor's subtree is where the war is fought.
  2. Walk down checking the seven context-creators in the table; the accidental one (usually opacity or transform on a wrapper) is the fence.
  3. 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.

Related tools