CSS z-index and stacking context
CSS z-index - sets the stacking order of positioned elements along the z-axis, but that comparison only happens within a stacking context, a local container that isolates its children's z-index values from the rest of the page.
Theory
TL;DR
- z-index only compares elements that share the same stacking context, not the whole page
- A child with z-index: 9999 inside a z-index: 1 parent loses to any element with z-index: 2 outside that parent
- Stacking contexts are created by
position+ non-autoz-index,opacity < 1,transform,filter,isolation: isolate, and a few others - Think of them as glass elevator shafts: elements inside one shaft never directly overlap elements in another shaft
- Fix z-index problems with
isolation: isolate, not by adding bigger numbers
Quick Example
.parent { position: relative; z-index: 5; } /* Creates a stacking context */
.child { position: absolute; z-index: 100; } /* Trapped inside parent */
.other { position: absolute; z-index: 6; } /* Wins — compares to parent z=5, not child z=100 */The child has z-index: 100 but still sits below .other. The browser compares .parent (z=5) against .other (z=6). The child's value does not enter that race. It only competes inside .parent.
How Stacking Contexts Work
When the browser paints the page, it sorts elements by the z-order of their stacking context root, not by every individual z-index. Each context is painted as a flat group. The browser does not look inside. A context with z-index: 1 is fully rendered before a context with z-index: 2, regardless of what individual values exist inside either one.
The root <html> element starts the first stacking context. Properties like position: relative; z-index: 1 trigger new ones recursively. Chrome (Blink engine) tracks each context as a PaintLayer object for GPU compositing.
What Creates a Stacking Context
| Property / Condition | Example |
|---|---|
| Root element | <html> |
position + z-index (not auto) | position: relative; z-index: 1 |
opacity < 1 | opacity: 0.99 |
transform (any value) | transform: translateX(0) |
filter (any value) | filter: blur(0) |
will-change | will-change: transform |
isolation: isolate | Creates a context explicitly |
position: fixed or sticky | Always creates a context |
Flex/grid child with z-index | z-index: 1 on a flex child |
The most common surprise is opacity: 0.99. Set it for a subtle fade and it quietly creates a stacking context. Suddenly a tooltip inside disappears behind a sidebar with no obvious reason.
When to Use
- Modal or drawer:
position: fixed; z-index: 1000at the root level. In React, useReactDOM.createPortal(modal, document.body)to escape any parent context. - Nested dropdown: apply
isolation: isolateto the parent component so inner z-index values do not affect elements outside. - Overlapping cards:
position: relative; z-index: autoon siblings works fine without creating new contexts. - Avoid large numbers: instead of
z-index: 99999, define a CSS custom property scale.
:root {
--z-dropdown: 100;
--z-modal: 400;
--z-tooltip: 600;
}
.modal { position: fixed; z-index: var(--z-modal); }
.tooltip { position: fixed; z-index: var(--z-tooltip); }Common Mistakes
z-index on a static element
.box { z-index: 10; } /* Has no effect */z-index is ignored unless position is relative, absolute, fixed, or sticky, or the element is a flex/grid child. Add position: relative and it works.
High z-index trapped in a low context
.parent { opacity: 0.99; } /* Creates a stacking context */
.tooltip { position: absolute; z-index: 9999; } /* Stuck inside parent */opacity triggers a new context. Anything outside with z-index: 1 beats the tooltip. Move it to a root-level container or raise the parent's z-index. The transform trap is the bug that takes longest to find in real projects. You check the z-index, raise it, check again, and nothing changes, because the problem is three levels up in the DOM.
Negative z-index hides behind the background
.overlay { position: absolute; z-index: -1; }Negative values place elements below the parent's background. That is rarely the intent. Use isolation: isolate on the parent if you need to clip a layer without z-index tricks.
Flex order vs. z-index confusion
.container { display: flex; }
.item1 { order: 2; z-index: 10; position: relative; }
.item2 { order: 1; z-index: 1; position: relative; transform: translateX(0); }.item2 ends up on top despite z-index: 1. The transform promotes it to its own compositing layer. Developers expect DOM order and visual order to match cleanly. They do not when compositing layers are involved.
Real-World Usage
- React Portal:
ReactDOM.createPortal(modal, document.body)places the modal in the root stacking context, bypassing any parent traps - TailwindCSS: the
z-50utility onfixedelements in Next.js apps follows this same model - Bootstrap:
.modal-backdropusesz-index: 1040inside a new context created byopacity: 0.5on the backdrop element - Modern alternative: the
::backdroppseudo-element and the Popover API handle overlays without manual z-index stacking in browsers that support them
Follow-up Questions
Q: What is the difference between z-index: auto and z-index: 0?
A: auto means the element participates in its parent's stacking context without creating a new one. 0 creates a new stacking context at z-level zero, which changes how its children stack.
Q: My modal has z-index: 9999 but it hides behind the navbar. Why?
A: The navbar or one of its ancestors likely has transform or filter, which creates a stacking context that sits higher. Inspect computed styles on every ancestor up to <body> and look for those properties.
Q: How does will-change: transform affect stacking?
A: It promotes the element to a GPU compositing layer and creates a stacking context before the animation starts. This prevents jank during transitions but can cause layout surprises if you forget the context was created ahead of time.
Q: Is there a case where z-index: auto and z-index: 0 behave identically?
A: Yes, when the element is not positioned and is not a flex or grid child. In that case, neither value does anything. The difference only appears when the element would otherwise create a stacking context.
Q: (Senior) Does z-index work across Shadow DOM boundaries?
A: No. Shadow roots create stacking contexts, and z-index does not pierce the shadow boundary. For cross-boundary stacking in Chrome 89+ you can use ::part() or slotted elements to expose specific parts of a component.
Examples
Basic stacking order
.card-a {
position: relative;
z-index: 1;
background: lightblue;
}
.card-b {
position: relative;
z-index: 2; /* Appears above card-a */
background: coral;
}Two siblings, both position: relative, both in the root stacking context. Their z-index values compare directly. The higher value wins. This is the simple case that works as expected.
Modal over a dashboard card (React Portal)
// Without the portal, the modal is trapped in the parent's z-index: 1 context
function Dashboard() {
return (
<div style={{ position: 'relative', zIndex: 1 }}>
<Card style={{ position: 'relative', zIndex: 10 }}>
Dashboard card
</Card>
{ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 1000 }}>
Modal content {/* Now in root context, wins over everything */}
</div>,
document.body
)}
</div>
);
}Without the portal, the modal sits inside the div with z-index: 1. Even at z-index: 1000, it cannot beat a sibling with z-index: 2 outside. The portal moves it to document.body, placing it in the root stacking context where the comparison is straightforward.
The transform trap
<div class="sidebar" style="position: relative; z-index: 10; transform: translateX(0);">
<!-- transform creates a stacking context here -->
<div class="tooltip" style="position: absolute; z-index: 9999;">
This tooltip is trapped inside sidebar
</div>
</div>
<div class="header" style="position: relative; z-index: 11;">
Header beats the tooltip despite tooltip z=9999
</div>The sidebar's transform creates a stacking context. The tooltip's z-index: 9999 only counts inside the sidebar. The header compares against the sidebar (z=10), not the tooltip, and wins with z=11. Remove transform from the sidebar or increase the sidebar's z-index to fix it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.