Understanding Z-Index and Stacking Contexts
You set z-index: 9999 on an element and it still appears behind something with z-index: 1. This happens because z-index does not work globally—it works within stacking contexts.
Understanding stacking contexts is essential for debugging z-index issues and building predictable layered interfaces.
Problem
Developers encounter these scenarios:
.modal {
z-index: 9999; /* Should be on top */
}
.dropdown {
z-index: 1; /* Lower, should be behind */
}
Yet the dropdown appears above the modal. Increasing z-index values does not help. The problem is not the numbers—it is the stacking context hierarchy.
Why It Happens
What Creates a Stacking Context
A stacking context is like a layer group. Elements within a stacking context are layered relative to each other, but the entire group is treated as a single unit when compared to sibling contexts.
These CSS properties create new stacking contexts:
/* Each of these creates a stacking context */
.context-creator {
position: relative; z-index: 1; /* position + z-index */
}
.another-creator {
opacity: 0.99; /* Any opacity < 1 */
}
.transform-creator {
transform: translateZ(0); /* Any transform */
}
.filter-creator {
filter: blur(0); /* Any filter */
}
.isolation-creator {
isolation: isolate; /* Explicit isolation */
}
NOTE:
position: relativealone does NOT create a stacking context. It requires a z-index value (includingz-index: 0) to create one.
The Hierarchy Problem
Consider this structure:
<div class="sidebar" style="position: relative; z-index: 1;">
<div class="dropdown" style="z-index: 9999;">
<!-- z-index: 9999 is huge! -->
</div>
</div>
<div class="main" style="position: relative; z-index: 2;">
<div class="modal" style="z-index: 1;">
<!-- z-index: 1 is tiny -->
</div>
</div>
The dropdown has z-index: 9999 but appears behind the modal with z-index: 1. Why?
Because the sidebar context (z-index: 1) is compared to the main context (z-index: 2) first. Main wins. Everything inside main appears above everything inside sidebar, regardless of individual z-index values.
Solution
Structure your stacking contexts intentionally and avoid creating unnecessary contexts.
Implementation
Define a z-index scale for your project:
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-popover: 600;
--z-tooltip: 700;
}
Use isolation: isolate to create explicit stacking contexts:
.card {
isolation: isolate; /* Creates context without side effects */
}
Example
Fixing the modal/dropdown issue:
<!-- Before: Broken hierarchy -->
<div class="app">
<div class="sidebar" style="position: relative; z-index: 1;">
<div class="dropdown" style="z-index: 9999;">...</div>
</div>
<div class="main" style="position: relative; z-index: 2;">
<div class="modal" style="z-index: 1;">...</div>
</div>
</div>
<!-- After: Flat context at app level -->
<div class="app">
<div class="sidebar">
<!-- No z-index on sidebar -->
</div>
<div class="main">
<!-- No z-index on main -->
</div>
<!-- Portaled elements at root level -->
<div class="dropdown" style="z-index: 100;">...</div>
<div class="modal" style="z-index: 500;">...</div>
</div>
By rendering modals and dropdowns at the root level (using React portals or similar), they share the same stacking context and z-index works predictably.
// React portal example
import { createPortal } from 'react-dom';
function Modal({ children }: { children: React.ReactNode }) {
return createPortal(
<div className="modal-backdrop">
<div className="modal">
{children}
</div>
</div>,
document.body // Render at body level
);
}
Common Mistakes
Adding z-index without position
Z-index has no effect on static elements:
/* Does nothing */
.element {
z-index: 100;
}
/* Works */
.element {
position: relative;
z-index: 100;
}
Escalating z-index values
When z-index stops working, developers often try higher values:
.modal {
z-index: 9999;
}
/* Later... */
.newer-modal {
z-index: 99999;
}
/* Even later... */
.newest-modal {
z-index: 999999999;
}
WARNING: This is a symptom, not a solution. The problem is stacking context hierarchy, not insufficient z-index values.
Opacity creating unwanted contexts
Adding even subtle opacity creates a stacking context:
.card:hover {
opacity: 0.95; /* Creates stacking context */
}
Now everything inside .card is confined to that context. Use filter or transform carefully for the same reason.
Debug technique
Add this to visualize stacking contexts:
* {
outline: 1px solid rgba(255, 0, 0, 0.2);
}
[style*="z-index"],
[style*="transform"],
[style*="opacity"],
[style*="filter"] {
outline: 2px solid blue !important;
}
Conclusion
Z-index works within stacking contexts, not globally. Debug by identifying which ancestor creates the relevant context. Use React portals to render overlays at the document root where z-index values can be compared directly. Define a z-index scale and stick to it.