- Published on
CSS Stacking Contexts, Why Your z-index Isn't Working
- Authors

- Name
- Alex Peng
- @aJinTonic
You've set z-index: 9999 on your modal. It still appears under the dropdown nav. You try z-index: 99999. Same problem. You ask on Stack Overflow and someone says "stacking context" and pastes a fix without explaining why it works.
Here's the explanation.
z-index Is Not Global
The first thing to understand: z-index values are only meaningful within the same stacking context. A stacking context is an isolated z-index scope. Elements in different stacking contexts cannot interleave, the entire stacking context is painted as a unit, and then that unit is placed relative to other stacking contexts.
Think of it like z-index within a Photoshop layer group. Elements inside the group can have their own z-order, but the group itself is positioned relative to other groups. No matter what z-index you give a layer inside the group, it can never jump outside the group's position in the overall stack.
What Creates a Stacking Context?
This is the list most people don't know. A new stacking context is created by:
- The root element (
<html>) - An element with
positionother thanstaticAND az-indexother thanauto - An element with
opacityless than 1 - An element with
transform,filter,perspective,clip-path, ormaskset to anything other thannone - An element with
isolation: isolate - An element with
will-changeset to certain properties
The transform and opacity ones catch people all the time. You add a fade-in animation to your sidebar container, which applies opacity: 0.99 during the transition, and suddenly your sidebar creates a stacking context that traps your modal underneath it.
A Concrete Example
<div class="nav" style="position: relative; z-index: 100">
<!-- nav content -->
</div>
<div class="sidebar" style="position: relative; z-index: 1; transform: translateZ(0)">
<div class="modal" style="position: fixed; z-index: 9999">
<!-- This modal is stuck inside .sidebar's stacking context -->
</div>
</div>
The .sidebar has transform: translateZ(0), a common GPU acceleration trick. This creates a new stacking context. The .modal inside it has z-index: 9999, but that 9999 is scoped to the .sidebar stacking context, which itself has z-index: 1. So the modal is behind .nav even though 9999 > 100.
How to Debug
Chrome DevTools has a Layers panel (in the three-dot menu → More tools → Layers) that lets you visualize stacking contexts. But for quick debugging, just look for the properties listed above on ancestor elements.
A fast way to check:
/* Temporarily add this to the modal's ancestors */
* { transform: none !important; opacity: 1 !important; filter: none !important; }
If your modal suddenly appears correctly, one of those properties on an ancestor was creating the problematic stacking context.
The Cleanest Fix
The best solution is usually to move the modal to a different part of the DOM rather than fighting z-index. Portals exist for exactly this reason, React's createPortal, Angular's CDK overlay, and Vue's <Teleport> all let you render a component's content at a different DOM location (usually directly under <body>):
import { createPortal } from 'react-dom'
function Modal({ children }) {
return createPortal(
<div className="modal-overlay">
{children}
</div>,
document.body // rendered here, outside any nested stacking context
)
}
If you can't move the element, use isolation: isolate on a common ancestor to create a deliberate stacking context boundary, and then ensure your z-index values make sense within that boundary.
The Rule
z-index wars are almost always a symptom of not understanding stacking contexts. Once you know what creates them, the fix is usually obvious: either move the element out of the problematic context, or don't create the context in the first place. Set high z-index values intentionally, not as cargo cult magic numbers.