How do z-index and stacking contexts work?
The Short Answer
z-index controls the stacking order of positioned elements (anything with position other than static). But it doesn't work in a single global layer — it operates within stacking contexts. A stacking context is like a self-contained layer group: elements inside it are stacked relative to each other, and the entire group is stacked as a unit in the parent context. This is why setting z-index: 9999 sometimes doesn't work — the element might be trapped inside a stacking context that's behind another one.
How z-index Works (The Basics)
By default, elements stack in document order — later elements appear on top of earlier ones. z-index lets you override this order, but only for positioned elements. An element with position: static (the default) ignores z-index entirely. Higher z-index values appear in front of lower ones within the same stacking context.
/* z-index only works on positioned elements */
.behind {
position: relative;
z-index: 1;
background: red;
}
.in-front {
position: relative;
z-index: 2; /* Higher = in front */
background: blue;
margin-top: -20px; /* Overlaps .behind */
}
/* ❌ z-index is IGNORED on static elements */
.broken {
position: static; /* default */
z-index: 9999; /* Does nothing! */
}
/* Default stacking order (no z-index, all positioned):
1. Root element background/borders
2. Non-positioned elements (in document order)
3. Positioned elements (in document order)
Within positioned elements, z-index controls order */
The first rule to remember: z-index requires position: relative, absolute, fixed, or sticky. Without positioning, z-index is completely ignored by the browser.
What Creates a Stacking Context
A stacking context is created whenever certain CSS properties are applied to an element. Once created, all children of that element are stacked within it — their z-index values only compete with siblings in the same context, not with elements outside it. The root <html> element always creates the initial stacking context.
- Root element (<html>) — always creates the base stacking context
- position: absolute/relative with z-index other than auto
- position: fixed or sticky (always creates a stacking context)
- opacity less than 1
- transform (any value other than none)
- filter (any value other than none)
- will-change: transform, opacity, or filter
- isolation: isolate (explicitly creates one)
- mix-blend-mode other than normal
- contain: layout or paint
- Flex/grid children with z-index other than auto
This is why adding transform: translateZ(0) or opacity: 0.99 as a 'performance hack' can break z-index stacking — these properties create new stacking contexts, changing how child elements layer relative to the rest of the page.
The Stacking Context Trap
The most common z-index frustration happens when an element is inside a stacking context with a lower z-index than a sibling context. No matter how high you set the child's z-index, it can never escape its parent's stacking context. The child's z-index only determines its position within the parent — the parent's z-index determines where the entire group sits relative to other contexts.
<!-- The classic z-index trap -->
<style>
.parent-a {
position: relative;
z-index: 1; /* Creates stacking context */
}
.parent-b {
position: relative;
z-index: 2; /* Creates stacking context — ABOVE parent-a */
}
.child-of-a {
position: absolute;
z-index: 99999; /* Doesn't matter! Trapped inside parent-a's context */
}
.child-of-b {
position: absolute;
z-index: 1; /* Still appears ABOVE child-of-a */
}
</style>
<div class="parent-a">
<div class="child-of-a">I'm z-index 99999 but BEHIND child-of-b</div>
</div>
<div class="parent-b">
<div class="child-of-b">I'm z-index 1 but IN FRONT of child-of-a</div>
</div>
<!--
Why? parent-a (z:1) is behind parent-b (z:2).
child-of-a's z:99999 only matters WITHIN parent-a.
The entire parent-a group (including all children) is behind parent-b.
-->
Think of stacking contexts like folders on a desk. You can reorder papers within a folder (child z-index), but the folder's position in the stack (parent z-index) determines whether those papers are above or below papers in other folders. No amount of reordering within a bottom folder puts a paper above the top folder.
Debugging z-index Issues
When z-index isn't working as expected, follow this debugging checklist. Most z-index bugs come from one of three causes: the element isn't positioned, it's trapped in a lower stacking context, or a parent has a property that accidentally creates a stacking context (like transform or opacity).
- Check if the element has position other than static (z-index requires it)
- Inspect parent elements — find which one creates the stacking context
- Look for transform, opacity < 1, filter, or will-change on ancestors
- Use DevTools 3D view (Chrome) to visualize stacking contexts
- Check if the competing element is in a different stacking context
- Consider using isolation: isolate to explicitly control context boundaries
Best Practices
Managing z-index at scale requires discipline. Without a system, you end up with z-index wars where values escalate to absurd numbers. Use a token-based approach with defined layers, avoid creating unnecessary stacking contexts, and use isolation: isolate to contain z-index scope intentionally.
/* ✅ Define z-index layers as CSS custom properties */
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-popover: 500;
--z-toast: 600;
--z-tooltip: 700;
}
.dropdown-menu {
position: absolute;
z-index: var(--z-dropdown);
}
.modal-backdrop {
position: fixed;
z-index: var(--z-overlay);
}
.modal {
position: fixed;
z-index: var(--z-modal);
}
.toast {
position: fixed;
z-index: var(--z-toast);
}
/* Use isolation to prevent z-index leaking */
.card {
isolation: isolate; /* Creates stacking context without side effects */
/* Children's z-index is now scoped to this card */
}
/* ❌ Avoid: z-index arms race */
.header { z-index: 999; }
.dropdown { z-index: 9999; }
.modal { z-index: 99999; }
.tooltip { z-index: 999999; } /* Where does it end? */
The isolation: isolate property is underused but powerful — it creates a stacking context without any visual side effects (unlike transform: translateZ(0) or opacity: 0.99). Use it to intentionally scope z-index within a component so it doesn't interfere with the rest of the page.
Why Interviewers Ask This
z-index and stacking contexts are one of the most misunderstood CSS concepts. Interviewers ask this to see if you understand why z-index 'doesn't work' sometimes (stacking context trap), know what properties create stacking contexts (not just position + z-index), can debug layering issues systematically, and have strategies for managing z-index at scale. It separates developers who understand the CSS rendering model from those who just increase z-index until it works.
Quick Revision Cheat Sheet
z-index requires: position: relative/absolute/fixed/sticky — ignored on static elements
Stacking context: Self-contained layer group — children's z-index only competes within it
Common creators: position + z-index, transform, opacity < 1, filter, fixed/sticky
The trap: Child can never escape parent's stacking context — z-index: 99999 won't help
isolation: isolate: Creates stacking context with no visual side effects — use for scoping
Best practice: Define z-index layers as tokens/variables — avoid arbitrary escalating values