CSS z-index Stacking Context — Why 9999 Lost

CSS z-index frontend

You set the modal to z-index: 9999. The sticky header still paints on top. Someone replies with the “just use 99999” meme. The meme is wrong. z-index only compares elements inside the same stacking context. Your modal’s 9999 lost to a header’s 10 because the modal lived inside a parent that created a context with an effective level below the header’s context.

What creates a stacking context

Not an exhaustive list, but the ones that burn product UI:

  • position not static and z-index not auto
  • opacity less than 1
  • transform, filter, perspective, will-change on those properties
  • isolation: isolate
  • fixed / sticky in many cases (browser-dependent details exist; treat as suspects)
  • Some flex/grid children with z-index
<header style="position: sticky; z-index: 50">nav</header>
<div style="transform: translateZ(0); position: relative; z-index: 1">
  <div class="modal" style="position: fixed; z-index: 9999">modal</div>
</div>

The transformed ancestor’s context sits at level 1 versus the header’s 50. Inside that ancestor, 9999 only wins against siblings in that context. Against the header, the whole group is painted as one unit at 1.

How to debug without guessing

  1. In Chrome DevTools, select the modal → Computed → look for stacking / use the Layers or 3D view when available.
  2. Walk up the DOM. Note any parent with transform, opacity, filter, or non-auto z-index.
  3. Ask: should the modal be a child of that parent? Portals exist for this reason.
// React: render modals at document body
return createPortal(<Modal />, document.body);

Escaping the transformed parent puts the modal in the root (or a dedicated overlay root) stacking context where your z-index scale is meaningful.

Design a z-index scale

Random 9999s are a smell. Use tokens:

:root {
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-modal: 300;
  --z-toast: 400;
}
.modal { z-index: var(--z-modal); }

If a dropdown inside a modal needs to exceed the modal chrome, either nest correctly or raise within the modal’s context — do not invent z-index: 10000 on the page root to compensate for a trapped context.

Opacity and “invisible” contexts

opacity: 0.99 on a page wrapper for a fade-in animation is enough to trap children. Prefer animating opacity on the element that is fading, not on a layout ancestor that wraps the whole app shell.

Same for transform on a card used for hover lift — a modal triggered from inside that card should portal out.

Checklist when “z-index ignored”

CheckAction
Parent has transform/filter/opacityPortal or remove the property from ancestor
Comparing across contextsRaise the context root, not the inner leaf
Mixing fixed inside transformed parentfixed may behave like absolute relative to that parent
Stacking with SVG/foreignObjectSeparate issues — simplify DOM

Mental model

Think of stacking contexts as nested folders. z-index sorts files inside a folder. It does not sort a file against something in a different folder with a higher folder order. Move the modal to the right folder (portal), or change the folder’s order — do not rename the file 99999.

Overlay roots as architecture

Serious UI kits render dialogs, toasts, and popovers into a dedicated #overlay-root at the end of document.body. That keeps stacking predictable. If modals still nest inside transformed cards, portal first; only then debate z-index tokens. Memes about z-index: 9999 spread because teams skip the portal step and invent larger integers instead.

Once you see the parent context, the meme loses its punchline — and the header finally sits where the design intended.