ESC

Type to search the knowledge base.

Stacking Contexts and z-index

Why z-index:9999 fails — stacking contexts from opacity, transform, position, and isolation, and how to debug overlay fights.

intermediate3 min read
  • css
  • stacking-contexts
  • z-index

z-index only compares elements inside the same stacking context. A child with z-index: 9999 cannot paint above a sibling of its parent if the parent itself sits under another context. Most “modal under header” bugs are stacking-context bugs, not small z-index numbers.

Docs: MDN stacking context, z-index.

When a stacking context is created

Common creators (not exhaustive):

  • position not static and z-index not auto
  • position: fixed / sticky (modern engines create contexts)
  • opacity less than 1
  • transform, filter, perspective, clip-path other than none
  • isolation: isolate
  • will-change of certain properties
  • Some flex/grid children with z-index
.header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: white;
}

.modal-root {
  position: relative;
  z-index: 1; /* whole subtree lives under header’s 10 if they’re siblings… */
}

If .modal-root is inside a transformed parent with a lower context than the header, raising the modal’s z-index only reorders within that parent.

Mental model

  1. Each stacking context is a private z-order universe.
  2. Contexts themselves are ordered by their parent’s rules.
  3. Descendants cannot escape their context’s “layer group.”
<div class="app">
  <header class="header">…</header>
  <div class="page">
    <div class="card" style="transform: translateZ(0)">
      <div class="dropdown" style="z-index: 9999">…</div>
    </div>
  </div>
</div>

The dropdown competes with other things inside .card first; the transformed .card competes with the header at a higher level.

Practical patterns

App chrome scale

:root {
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-modal: 300;
  --z-toast: 400;
}

.dropdown { z-index: var(--z-dropdown); }
.header { z-index: var(--z-sticky); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }

Use a token scale, not random 9999s.

Portals

Render modals/toasts at document.body (React portals) so they’re not trapped under transformed page sections.

isolation

.card {
  isolation: isolate; /* create a clean local context for blend modes / z children */
}

Debugging checklist

  1. In DevTools, inspect the element — is it inside transform/opacity/filter?
  2. Compare the ancestors, not only the two elements.
  3. Check position + z-index pairs.
  4. Look for sticky headers with high z-index.
  5. Prefer portal + known root stacking for overlays.

Interview out-loud

“z-index works within a stacking context. Transforms, opacity, filters, and positioned elements with z-index create new contexts. A huge z-index can’t escape its parent context. I use a small z-index scale, portal modals to the body, and inspect ancestors when overlays mis-order.”

Footguns

  1. opacity: 0.99 “for anti-aliasing” creating contexts.
  2. Animating transform on a parent of a dropdown.
  3. z-index on non-positioned elements (ignored for comparison in many cases).
  4. Infinite z-index inflation across teams.
  5. Expecting isolation to fix everything without understanding parent order.
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / 0.45);
  z-index: var(--z-modal);
}
.modal {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) + 1);
  display: grid;
  place-items: center;
  pointer-events: none;
}
.modal__panel {
  pointer-events: auto;
}

Render both via a portal at document.body so a transformed dashboard widget cannot trap them. Focus trap and aria-modal are separate concerns — stacking only solves paint order.

Further reading

Related guides