Stacking context: why z-index doesn't work
z-index only compares siblings within the same stacking context. Once you understand that rule, layering stops being mysterious.
What is a stacking context?
A stacking context is a three-dimensional conceptualization of HTML elements along the z-axis. Within a stacking context, child elements are stacked according to their z-index values. Crucially, a child's z-index only competes with siblings in the same stacking context — it can never escape its parent's context.
/* Parent creates a stacking context with z-index: 1 */
.card {
position: relative;
z-index: 1;
}
/* This z-index: 9999 is trapped inside .card's context */
/* It will NOT appear above a sibling of .card with z-index: 2 */
.card .tooltip {
position: absolute;
z-index: 9999;
}
This is why cranking up z-index to absurd values doesn't always fix layering issues. The problem is usually a stacking context boundary, not the number.
What creates a stacking context
Many CSS properties create new stacking contexts. Here are the most common triggers:
position: relative/absolute/fixed/stickywith az-indexvalue other thanautoopacityless than1transform,filter,backdrop-filter, orperspectivewith any value other thannoneisolation: isolatewill-changespecifying any of the above propertiesmix-blend-modeother thannormalcontain: layoutorcontain: paint- Flex/grid children with a
z-indexvalue other thanauto
z-index without position: relative. This creates a stacking context implicitly, which catches many developers off guard.The isolation property
The isolation property exists specifically to create a stacking context without any visual side effects. Unlike opacity: 0.999 or transform: translateZ(0) hacks, isolation: isolate has no rendering impact:
/* Create a stacking context intentionally */
.component {
isolation: isolate;
}
/* Now all z-index values inside .component are scoped */
/* They won't leak out and interfere with other components */
.component .overlay { z-index: 10; }
.component .content { z-index: 1; }
/* Other components are unaffected */
.other-component .dropdown { z-index: 5; }
Think of isolation: isolate as a z-index firewall. It contains the stacking order within a component boundary.
z-index management strategy
Instead of arbitrary numbers scattered across your codebase, define a z-index scale using custom properties. This makes the stacking order explicit and maintainable:
:root {
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-toast: 50;
}
.dropdown { z-index: var(--z-dropdown); }
.site-header { z-index: var(--z-sticky); }
.overlay { z-index: var(--z-overlay); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }
Debugging stacking issues
When an element won't layer correctly, follow this debugging process:
- Step 1: Check if the element has
positionset. Without it (outside flex/grid),z-indexhas no effect. - Step 2: Walk up the DOM tree and look for ancestors that create stacking contexts. Any ancestor with
opacity,transform,filter, orz-indexis a boundary. - Step 3: Compare the stacking contexts. The element's context must be a sibling of (or the same as) the context of the element you want to stack against.
- Step 4: If an unwanted stacking context exists, consider removing the property that creates it, or restructure the DOM.
/* Common accidental stacking context triggers */
.card {
/* Each of these creates a stacking context: */
opacity: 0.99; /* remove if unnecessary */
transform: translate(0); /* remove if unnecessary */
filter: drop-shadow(0 0 0); /* use box-shadow instead */
will-change: transform; /* remove after animation */
}
Component-scoped stacking
The most maintainable approach isolates each component's stacking context, then manages only the top-level layers globally:
/* Every component is isolated */
.card,
.nav,
.sidebar,
.dialog {
isolation: isolate;
}
/* Only top-level layers need global z-index */
.page-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal);
}
With this pattern, internal z-index values inside a component (like a card's hover overlay at z-index: 2) can never accidentally appear above the modal backdrop. The stacking context boundary guarantees containment.