CSS z-index Stacking Context — Why 9999 Lost
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:
positionnotstaticandz-indexnotautoopacityless than 1transform,filter,perspective,will-changeon those propertiesisolation: isolatefixed/stickyin many cases (browser-dependent details exist; treat as suspects)- Some
flex/gridchildren withz-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
- In Chrome DevTools, select the modal → Computed → look for stacking / use the Layers or 3D view when available.
- Walk up the DOM. Note any parent with
transform,opacity,filter, or non-autoz-index. - 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”
| Check | Action |
|---|---|
| Parent has transform/filter/opacity | Portal or remove the property from ancestor |
| Comparing across contexts | Raise the context root, not the inner leaf |
Mixing fixed inside transformed parent | fixed may behave like absolute relative to that parent |
| Stacking with SVG/foreignObject | Separate 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.