When z-index Doesn't Work: Understanding Stacking Contexts
1. How I Encountered This Problem
I created a Modal component and gave it z-index: 9999.
But it still appeared behind the Header (z-index: 10).
.modal {
z-index: 9999;
}
.header {
z-index: 10;
}
"Is 9999 not big enough? Should I try 999999?" I increased the number, but nothing changed. The modal was trapped behind the header.
2. What Confused Me Initially?
My misconception was: "Higher z-index ALWAYS comes on top". I thought z-index was a global ranking system.
But CSS doesn't work like that. z-index only compares elements within the same Stacking Context.
3. The Aha Moment: The "Rooms" Analogy
I finally understood it with this analogy:
"Imagine a building with many rooms (Stacking Contexts)."
- Inside a room, people line up by height (z-index).
- But a tall person in Room A cannot be compared to a short person in Room B.
- If Room A is located on the 1st floor, and Room B is on the 2nd floor, everyone in Room B is higher than everyone in Room A, regardless of their individual height.
In my case:
- Header was in the "Root Room" at z-index 10.
- Modal was inside a "Child Room" (maybe a Sidebar) that had z-index 1.
- So
1 (Parent) < 10 (Header). The Modal's9999didn't matter because its parent was already lower.
4. Deep Dive: How Stacking Contexts are Created
Most people think only position + z-index creates a context.
But modern CSS has many triggers.
Common Triggers:
- Position & z-index:
position: relative/absolute/fixed+z-index(not auto). - Opacity:
opacityless than 1. - Transform:
transform: scale/rotate/translate. - Filter:
filter: blur/contrast. - Will-change:
will-change: transform. - Isolation:
isolation: isolate(Explicitly creates a context).
/* This creates a new Stacking Context! */
.card {
opacity: 0.99;
/* Now z-index inside .card is isolated from outside */
}
5. The Solution: Break Out of the Room
Solution 1: React Portals (The Best Way)
Don't fight the stacking context. Just move the Modal to the Root (document.body).
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(
<div className="modal">{children}</div>,
document.body // Move directly to <body>
);
}
Now the Modal is a direct child of Body, so its z-index: 9999 competes globally.
Solution 2: Remove the Trap
Check the parent elements of your Modal.
Does any parent have overflow: hidden, opacity, or transform?
If so, try to remove those properties or move the Modal outside that parent.
6. CSS Isolation: A New Hope
Sometimes you Want to create a new context to reset z-index.
Use isolation: isolate.
.new-context {
isolation: isolate;
/* This explicitly starts a new stacking context */
}
This is safer than using transform: translateZ(0) hacks.
7. Deep Dive: The Browser Rendering Pipeline
To truly master z-index, you must understand how Browsers render pages.
- DOM Tree: The HTML structure.
- Render Tree: DOM + CSS.
- Layer Tree: This is where Stacking Contexts live.
- The browser "Flatterns" the page into layers.
- If an element creates a Stacking Context (e.g., has
opacity: 0.9), the browser promotes it to a new Graphics Layer. - This layer is painted separately and then Composited (glued together) by the GPU.
The Trap: If "Parent A" is on "Layer 1" and "Parent B" is on "Layer 2" (higher). No matter what z-index the child of A has, it is physically painted onto Layer 1. It cannot jump out of Layer 1 to cover Layer 2. It's like drawing on a piece of paper; you can't draw "above" the paper above you.
Why transform creates a context?
Because transform often triggers GPU acceleration. The browser puts that element on a separate layer to rotate/scale it cheaply. Thus, a new Stacking Context is born.
8. Managing z-index in Large Projects
Stop using Magic Numbers (9999, 99999).
Use a Sass Map or CSS Variables to manage layers systemically.
// _z-index.scss
$layers: (
'toast': 9000,
'modal': 8000,
'overlay': 7000,
'dropdown': 5000,
'header': 1000,
'base': 1
);
@function z($layer) {
@return map-get($layers, $layer);
}
// usage
.modal {
z-index: z('modal'); // 8000
}
This acts as a "Single Source of Truth" for your vertical layout.
9. Quiz: Will it Stack?
Let's test your knowledge.
Scenario A:
- Parent:
relative,z-index: 10 - Child:
absolute,z-index: -1 - Result: The child goes behind the parent's content but in front of the parent's background. It does NOT go behind the parent's stacking context.
Scenario B:
- Element A:
opacity: 0.99,z-index: 999 - Element B:
fixed,z-index: 1000 - Result: B is on top. Both create contexts, but B has higher z-index in the root context.
Scenario C:
- Element A:
transform: rotate(0deg),z-index: 999 - Child of A:
fixed,z-index: 1000 - Result: This is tricky. In strict standards,
transformmakesfixedchildren behave likeabsolute(relative to the transformed parent, not the viewport). So the Child might be trapped inside A!
9.5. The Ultimate z-index Debugging Checklist
Next time you are stuck, check this list:
- Is
positionset? (Static elements ignore z-index). - Is the parent trapping it? (Check parent for
overflow: hidden,opacity,transform,filter). - Are they in the same context? (If not, move the HTML or use Portal).
- Is there a negative margin? (Sometimes content overlaps physically but the stacking is correct).
- Did you restart the dev server? (Just kidding, but CSS caching is real).
10. One-Line Summary
z-index is not global; it's relative to the Stacking Context. If your element is stuck, use React Portals to move it to the root level.
11. Bonus: A Brief History of CSS Layout
Understanding history helps you understand why CSS is weird.
- The Table Era (1990s): We used
<table>for layout. It was a dark time. No z-index needed because everything was a grid. - The Float Era (2000s): We abused
float: leftandclearfix. This is whenz-indexwars began. - The Positioning Era:
absolutebecame popular for precise control, leading to "z-index: 9999" hell. - The Flexbox/Grid Era (Modern): Finally, we have a layout engine. But Stacking Contexts remain the confusing legacy of the Positioning Era.