Stacking Context and z-index
Understand how the browser decides which elements paint on top of which, what creates a new stacking context, and why increasing z-index sometimes has no visible effect — the classic bug every developer eventually hits.
Almost every developer has, at some point, set z-index: 9999 on an element and watched it stay stubbornly behind something else. The z-index property doesn't work the way most people assume: it isn't a single global ranking across the whole page. It only compares elements within the same stacking context — and understanding what a stacking context is turns this from a mystery into a predictable, debuggable system.
Default stacking order (no z-index involved)
Even without any z-index at all, elements already stack in a defined order, back to front: the root element's background and borders first, then non-positioned block-level elements in HTML source order, then floated elements, then normal inline content, and finally positioned elements (position other than static) in source order.
This is why a position: relative element with no z-index still visually sits above a normal static <div> that comes after it in the HTML — merely being positioned bumps it up in the default stacking order, even before z-index enters the picture.
z-index only works on positioned elements
z-index has no effect at all on an element whose position is the default, static. It only takes effect once an element has position: relative, absolute, fixed, or sticky (or is a flex/grid item, where z-index also applies directly). Setting z-index: 100 on a position: static element does nothing.
What creates a new stacking context
A stacking context is a self-contained group: once formed, all of its descendants are stacked relative to each other and to the context's own position, and that entire group is then compared as a single unit against siblings outside the context. Several common CSS properties create a new stacking context on the element they're applied to, not just position + z-index:
• position: relative/absolute/fixed/sticky combined with a z-index value other than auto • opacity less than 1 (e.g. opacity: 0.99) • transform with any value other than none • filter, backdrop-filter, or perspective with a value other than none • will-change set to a property that would create a stacking context • isolation: isolate — created specifically to force a new stacking context with no other visual side effect • The root element itself always establishes the outermost stacking context
Why a high z-index sometimes still loses
This is the classic bug: an element with z-index: 9999 stays hidden behind an element with z-index: 1, because they belong to different stacking contexts, and z-index values are never compared across context boundaries. Once a parent forms a stacking context, its entire subtree — however high its internal z-index values go — is stacked as one unit relative to that parent's siblings. A child's z-index: 9999 can never escape and outrank an element outside its ancestor's stacking context; it can only win against other elements inside the same context.
In practice this most often happens when a modal or tooltip is placed inside a card that has, say, transform: translateY(0) or opacity: 0.98 for an animation — that transform or opacity innocently created a new stacking context on the card, silently trapping the modal's z-index inside it.
Debugging z-index problems
When z-index doesn't behave as expected, walk up the DOM from the misbehaving element and check every ancestor for position + non-auto z-index, opacity < 1, a transform, a filter, or isolation: isolate — any one of those forms a new stacking context that traps the descendant. Browser devtools (Chrome and Firefox both have a '3D view' or stacking-context inspector in their layout panels) can visualize this directly. The practical fix is usually one of: remove the property that's accidentally creating the unwanted context, raise the z-index on the ancestor itself (moving the whole context up, not just the child), or restructure so the element that needs to be on top isn't nested inside a context-creating ancestor at all — for example, rendering a modal at the end of <body> instead of deep inside a component tree.
z-index basics
z-index accepts auto (default), or any positive or negative integer Negative z-index values are legal and place an element behind its normal-flow siblings isolation: isolate creates a stacking context without changing appearance, useful for containing z-index deliberately
.element {
position: relative; /* z-index has no effect without this (or absolute/fixed/sticky) */
z-index: 10; /* higher values paint on top of lower values, WITHIN the same context */
}
/* properties that silently create a new stacking context */
.card {
transform: translateY(0); /* creates a stacking context */
opacity: 0.99; /* also creates a stacking context */
}
/* force a new stacking context intentionally, with no visual side effect */
.isolated {
isolation: isolate;
}Code Examples
It looks like the modal, with z-index: 9999, should sit above everything. But if .modal is nested inside .card in the HTML, .card's transform: scale(1) creates a new stacking context that contains .modal. The modal's huge z-index only wins comparisons inside .card's context — against anything outside .card, the whole .card subtree is compared as one unit, using .card's own stacking position, not the modal's 9999.
Common mistake: Nesting a high z-index element inside an ancestor that unintentionally creates a stacking context (here, via transform).
With the transform removed from .card, .card no longer forms its own stacking context, so .modal's z-index: 9999 is now compared directly against the rest of the page's top-level stacking contexts and behaves as expected. In real apps, a more robust fix is often rendering the modal at the end of <body> via a portal, so it's never nested inside a component that might create a context.
All three cards share the same parent stacking context (.stack), so their z-index values are directly comparable: card-front (3) paints above card-mid (2), which paints above card-back (1). This is the straightforward case z-index is designed for — it only gets confusing once nested stacking contexts enter the picture.
.card {
position: relative;
transform: scale(1); /* creates a new stacking context! */
}
.modal {
position: fixed;
z-index: 9999;
}.card {
position: relative;
/* transform removed, or moved to a :hover-only state */
}
.modal {
position: fixed;
z-index: 9999;
}.stack {
position: relative;
}
.card-back { position: absolute; z-index: 1; }
.card-mid { position: absolute; z-index: 2; }
.card-front { position: absolute; z-index: 3; }Stacking contexts trap z-index
Because .modal is nested inside .card's stacking context, its z-index: 9999 is never compared directly against .toast — only .card as a whole unit is.
Page (root stacking context)
|
+-- .card (transform: scale(1) -> NEW stacking context)
| |
| +-- .modal { z-index: 9999 } <-- trapped inside .card's context
|
+-- .toast { z-index: 5 } <-- outside .card entirely
Comparison that actually happens:
.card's own stacking position vs .toast's z-index: 5
NOT: .modal's 9999 vs .toast's 5Important Rules
z-index only has an effect on positioned elements (position other than static), plus flex/grid items.
z-index values are only meaningfully compared between elements that share the same stacking context.
position + non-auto z-index, opacity < 1, transform, filter, backdrop-filter, perspective, and isolation: isolate all create new stacking contexts.
A descendant's z-index can never let it escape and outrank elements outside its ancestor's stacking context.
Negative z-index values place an element behind normal, non-positioned content in the same context.
Where this bites in real UI work
Tooltips, dropdown menus, sticky headers, and modal dialogs are the components that hit stacking-context bugs most often, because they're frequently nested inside cards or panels that use transform for hover animations or opacity for fade-in effects. The professional convention is to render overlays (modals, toasts, dropdown menus) at the root of the document — as a sibling of <body>'s main content, not nested inside deeply-styled components — specifically to sidestep this whole class of bug..dropdown { z-index: 999; }.dropdown { position: absolute; z-index: 999; } z-index is completely ignored on elements with the default position: static. Without a position value of relative, absolute, fixed, or sticky, this z-index: 999 does nothing at all..parent { transform: translateX(0); }.child-tooltip { position: absolute; z-index: 99999; } /* tooltip still hidden behind an unrelated absolutely-positioned sibling of.parent */.parent { /* transform removed or applied only conditionally */ }.child-tooltip { position: absolute; z-index: 99999; }
The seemingly unrelated transform on .parent silently creates a stacking context that traps .child-tooltip's z-index inside it, so no matter how high that number goes, it can't outrank elements outside .parent. Removing the unnecessary context-creating property (or restructuring the markup) is the actual fix — raising the z-index further won't help.
[Multiple Choice] An element has position: absolute; z-index: 500. Its parent has opacity: 0.9 and no z-index set. A sibling of the parent, elsewhere in the DOM, has position: absolute; z-index: 10. What determines whether the z-index: 500 element visually appears above that sibling? A. The z-index: 500 element always wins, since 500 is greater than 10 B. It depends on comparing the parent's own stacking position (not 500) against the sibling's z-index: 10, because the parent's opacity created a new stacking context that traps the child's z-index inside it C. z-index comparisons are decided purely by DOM source order, not by the z-index values at all D. The comparison is invalid CSS and the browser ignores z-index on both elements
B. It depends on comparing the parent's own stacking position (not 500) against the sibling's z-index: 10, because the parent's opacity created a new stacking context that traps the child's z-index inside it — opacity: 0.9 on the parent creates a new stacking context. The child's z-index: 500 is only meaningful inside that context — from outside, the entire parent subtree is compared as one unit using the parent's own stacking context position (which has no explicit z-index, so it participates in normal document order among its own siblings), not the child's 500.
A tooltip with z-index: 500 is invisible behind a sidebar with z-index: 10. The tooltip is nested inside a card that has opacity: 0.95 for a subtle fade effect. Explain why the tooltip is hidden, and describe two different fixes.
The card's opacity: 0.95 creates a new stacking context, trapping the tooltip's z-index: 500 inside it. The card itself (not the tooltip) is what gets compared against the sidebar, and the card's own stacking position loses to the sidebar. Fix 1: remove opacity from the card, or apply it only via a pseudo-element/child instead of directly on the ancestor. Fix 2: move the tooltip's markup out of the card entirely (e.g. render it at the end of <body>) so it's no longer nested inside a context-creating ancestor.
opacity values below 1 are on the list of properties that create a new stacking context.