Z-Index & Stacking Contexts
Every z-index war in a codebase traces back to one misunderstanding: z-index doesn't create a global layer system. It only orders elements within the same stacking context. A z-index: 9999 inside one context can be painted behind a z-index: 1 in a parent context.
The Default Stacking Order
Without z-index, elements paint in this order (back to front):
1. Root element background and borders
2. Descendant non-positioned elements (in DOM order)
3. Descendant positioned elements (in DOM order)<div class="red" style="position: relative;">I appear on top</div>
<div class="blue">I appear behind, even though I'm after in DOM</div>Positioned elements always paint above non-positioned ones, regardless of DOM order.
What Creates a Stacking Context
A stacking context is created by any of these conditions:
| Trigger | Example |
|---|---|
| Root element | <html> |
position + z-index (not auto) | position: relative; z-index: 1 |
position: fixed or position: sticky | Always creates context |
opacity less than 1 | opacity: 0.99 |
transform (any value) | transform: translateZ(0) |
filter (any value) | filter: blur(0) |
will-change (transform, opacity, etc.) | will-change: transform |
isolation: isolate | Explicit stacking context |
contain: layout or contain: paint | CSS containment |
Flex/grid child with z-index (not auto) | display: flex parent, child has z-index |
mix-blend-mode (not normal) | Compositing operations |
clip-path | Any clip-path value |
mask | Any mask value |
The Common Trap
.parent {
position: relative;
z-index: 1; /* Creates stacking context */
}
.child {
position: absolute;
z-index: 9999; /* Only competes within .parent's context */
}
.sidebar {
position: relative;
z-index: 2; /* Beats .parent (and all its children) */
}The child's z-index: 9999 is irrelevant â it can never paint above .sidebar because .parent's z-index: 1 loses to .sidebar's z-index: 2. The child is trapped.
isolation: isolate
The cleanest way to create a stacking context without side effects:
.modal-container {
isolation: isolate;
}Unlike transform: translateZ(0) or opacity: 0.99, isolation: isolate has no visual side effects. It exists solely to create a stacking context.
Component Isolation Pattern
.component {
isolation: isolate;
}Every self-contained component should create its own stacking context. This prevents internal z-index values from leaking out and conflicting with siblings.
.card {
isolation: isolate;
}
.card__overlay {
position: absolute;
z-index: 10;
}
.card__badge {
position: absolute;
z-index: 20;
}These z-index values are local to .card â they cannot interfere with anything outside.
Z-Index Scale Convention
Instead of arbitrary numbers, define a scale:
:root {
--z-below: -1;
--z-default: 0;
--z-raised: 1;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-popover: 500;
--z-toast: 600;
--z-tooltip: 700;
}
.dropdown { z-index: var(--z-dropdown); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }This eliminates the "add one more" pattern that leads to z-index: 99999.
Debugging Stacking Contexts
In Chrome DevTools:
- Elements panel â select an element
- Check the "Computed" tab â search for
z-index - The Layers panel (More Tools â Layers) visualizes 3D stacking
The mental model for debugging:
1. Is the element positioned? (z-index only works on positioned, flex/grid children)
2. What stacking context is it in? (Walk up the tree)
3. What are the z-index values of sibling stacking contexts?
4. Is an ancestor creating an unexpected stacking context? (opacity, transform, filter)Common z-index Bugs
Modal Behind Overlay
.page-content {
position: relative;
z-index: 1; /* Creates context â traps everything inside */
}
.modal {
position: fixed;
z-index: 99999; /* Stuck inside .page-content's context */
}Fix: Remove unnecessary z-index from .page-content, or move the modal outside the stacking context in the DOM (portal pattern in React).
Transform Creating Unwanted Context
.parent {
transform: translateX(0); /* Creates stacking context! */
}
.child {
position: fixed;
/* Now "fixed" is relative to .parent, not viewport */
/* z-index trapped inside .parent */
}Fix: Avoid applying transform to ancestors of position: fixed elements.
Interview Signal
Senior candidates demonstrate:
- Stacking context model â Can enumerate what creates contexts and explain why z-index values are local, not global
isolation: isolateâ Knows the clean way to create contexts for component encapsulation- Debugging approach â Walks up the tree to find the context, doesn't add larger z-index values
- Scale convention â Uses token-based z-index systems, not arbitrary escalating numbers
- Portal awareness â Understands that React portals solve the DOM-nesting stacking context trap