
The Stacking Context Demystified: Why Your z-index Isn't Working
Every front-end developer has been here. A dropdown menu disappears behind a hero section. You give it z-index: 10. Nothing. You try z-index: 999. Still nothing. In desperation, you type z-index: 99999 and it stubbornly stays underneath. At that point it's tempting to believe z-index is broken, but it isn't. It's doing exactly what the spec says, inside a structure most of us were never taught: the stacking context.
Once you understand stacking contexts, z-index stops being a guessing game. In this article we'll look at how browsers decide what paints on top of what, which properties quietly create new stacking contexts, how to debug layering problems, and how to structure your CSS so these bugs don't come back.
z-index Only Works on Certain Elements
Before stacking contexts, there's a simpler trap. z-index has no effect on a plain, statically positioned element:
.card {
z-index: 10; /* ignored */
}
z-index applies to elements that are positioned (position set to relative, absolute, fixed, or sticky) and to flex and grid items, even when they're not positioned. So this works:
.card {
position: relative;
z-index: 10;
}
And so does this, because the cards are grid items:
.card-grid {
display: grid;
}
.card-grid > .card {
z-index: 10;
}
If your z-index does nothing at all, check this first. If it does something, but not what you expected, keep reading.
What Is a Stacking Context?
A stacking context is a group of elements that are layered together as a single unit. Think of it as a sheet of paper. You can arrange items on the sheet in any order you like, but the whole sheet sits at one position in the pile relative to other sheets. Nothing on one sheet can slip between the items on another sheet.
The root element (html) forms the first stacking context. Certain CSS properties create new, nested stacking contexts. Inside each one, children are ordered by their z-index, but those values only compete with siblings in the same context. They're never compared with elements in a different context.
That's the whole secret. Your z-index: 99999 dropdown lives inside a stacking context whose own position in the pile is below the hero. The dropdown can be the top item on its sheet, but the whole sheet is underneath.
A Concrete Example
Here's the classic bug in miniature:
<header class="site-header">
<nav class="menu">
<button class="menu__toggle">Products</button>
<ul class="menu__dropdown">
<li><a href="/analytics">Analytics</a></li>
<li><a href="/billing">Billing</a></li>
</ul>
</nav>
</header>
<section class="hero">
<h1>Ship faster</h1>
</section>
.site-header {
position: relative;
z-index: 1;
}
.menu {
position: relative;
}
.menu__dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 9999;
}
.hero {
position: relative;
z-index: 2;
}
The header has position: relative and z-index: 1, which creates a stacking context. The dropdown's z-index: 9999 only ranks it within the header's context. At the root level, the comparison that matters is header (1) versus hero (2), and the hero wins. The dropdown goes wherever the header goes: underneath.
The fix is to raise the context, not the child:
.site-header {
position: relative;
z-index: 3; /* now above .hero */
}
.menu__dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1; /* only needs to beat siblings inside the header */
}
Notice that the dropdown's value dropped to 1. Inside the header, it only needs to beat its own neighbors.
The Painting Order Inside a Stacking Context
Within a single stacking context, the browser paints layers in this order, from back to front:
- The background and borders of the element forming the context.
- Descendants with a negative
z-index, lowest first. - Non-positioned, block-level descendants in document order.
- Non-positioned floats.
- Non-positioned inline content (text, inline images).
- Positioned descendants with
z-index: autoor0, in document order. - Descendants with a positive
z-index, lowest first.
Two practical consequences fall out of this list:
- When two positioned elements have the same
z-index(or bothauto), the one that comes later in the HTML paints on top. - A child with a negative
z-indexpaints behind its parent's content but in front of the parent's background, only if the parent forms a stacking context. Otherwise it drops behind the parent entirely.
That second point explains a common decorative trick:
.fancy-button {
position: relative;
isolation: isolate; /* create a stacking context */
background: transparent;
}
.fancy-button::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: linear-gradient(135deg, #38bdf8, #818cf8);
}
Without isolation: isolate, the pseudo-element's z-index: -1 would push it behind the button's parent section too, and it could disappear behind a section background.
Properties That Create a Stacking Context
This is where most surprises come from. A lot of properties create a stacking context as a side effect, even though they have nothing to do with layering. The most common ones:
position: relativeorabsolutewith az-indexother thanauto.position: fixedorposition: sticky(always, regardless ofz-index).- A flex or grid item with a
z-indexother thanauto. opacityless than1.transform,scale,rotate, ortranslatewith any value other thannone.filterorbackdrop-filterother thannone.mix-blend-modeother thannormal.clip-pathormaskother thannone.isolation: isolate.contain: layout,contain: paint, orcontain: strict/content.container-type: sizeorinline-size.will-changenaming any property that would create a stacking context (for example,will-change: transform).- Elements in the top layer, such as a modal
<dialog>or a popover.
The sneaky ones are opacity, transform, and will-change. A fade-in animation that starts at opacity: 0 creates a stacking context for as long as the opacity is below 1. An entrance animation using transform does the same. That's why a dropdown sometimes works fine until you add a subtle animation to its parent, and then suddenly it's buried.
/* Innocent-looking, but this creates a stacking context */
.card {
transition: transform 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
}
Any tooltip inside .card is now trapped in the card's context while it's hovered. If the next card in the grid paints later in document order, the tooltip will slide under it.
Debugging Stacking Problems
When an element appears in the wrong layer, work through it methodically rather than bumping numbers.
Step 1: Walk Up the Tree
Start at the element that's hidden and walk up its ancestors, noting which ones form a stacking context. The first ancestor that forms a context is the one whose position in the pile actually matters.
This small console helper does the walk for you. Select the element in the Elements panel so it's available as $0, then run:
function stackingAncestors(el) {
const out = [];
for (let node = el.parentElement; node; node = node.parentElement) {
const s = getComputedStyle(node);
const reasons = [];
if (s.position !== "static" && s.zIndex !== "auto")
reasons.push(`z-index ${s.zIndex}`);
if (s.position === "fixed" || s.position === "sticky")
reasons.push(s.position);
if (s.opacity !== "1") reasons.push(`opacity ${s.opacity}`);
if (s.transform !== "none") reasons.push("transform");
if (s.filter !== "none") reasons.push("filter");
if (s.isolation === "isolate") reasons.push("isolation");
if (s.mixBlendMode !== "normal") reasons.push("mix-blend-mode");
if (s.willChange !== "auto") reasons.push(`will-change ${s.willChange}`);
if (s.containerType && s.containerType !== "normal")
reasons.push("container-type");
if (reasons.length) out.push({ node, reasons });
}
return out;
}
console.table(stackingAncestors($0));
It doesn't cover every trigger, but it catches the usual suspects.
Step 2: Find the Competing Context
Now do the same for the element that's wrongly on top. Look for the point where the two ancestor chains share a common stacking context. The two children of that shared context are the elements whose z-index values are actually being compared.
Step 3: Fix at the Right Level
You now have three options:
- Raise the context that contains the hidden element, as we did with the header.
- Remove the accidental context by dropping an unnecessary
transform,opacity, orz-index. - Move the element out of the trapping context, either in the DOM or with the top layer (below).
Browser DevTools can help too. Chromium's Layers panel (under More tools) shows compositing layers, which often line up with stacking contexts, and some extensions can visualize stacking contexts directly.
Escaping Stacking Contexts with the Top Layer
Sometimes the element simply can't live where it's rendered in the DOM. A tooltip inside a card that uses overflow: hidden and a transform will be clipped and trapped no matter what z-index you give it.
Modern browsers offer a way out: the top layer. Elements shown with the Popover API or as a modal <dialog> render above everything else on the page, outside every stacking context, regardless of where they sit in the DOM.
<div class="card">
<button popovertarget="plan-info">What's included?</button>
<div id="plan-info" popover class="info-popover">
Unlimited projects, priority support, and SSO.
</div>
</div>
.info-popover {
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
}
The popover escapes the card's stacking context and its overflow clipping entirely. No z-index required. The Popover API is supported in all current major browsers. Combine it with CSS anchor positioning (where supported) to place it next to its trigger.
A Maintainable z-index Strategy
Most z-index chaos comes from values chosen ad hoc. A few habits prevent it.
Use a Named Scale
Define your layers once, as custom properties:
:root {
--z-base: 0;
--z-raised: 10;
--z-sticky: 100;
--z-dropdown: 200;
--z-overlay: 900;
--z-modal: 1000;
--z-toast: 1100;
}
.site-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.toast-region {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: var(--z-toast);
}
Now the relative order of your UI layers is readable in one place, and nobody has to reach for 99999.
Isolate Components
Give each component that uses z-index internally its own stacking context with isolation: isolate. The internal values then can't leak out and fight with the rest of the page:
.media-card {
position: relative;
isolation: isolate;
}
.media-card__image {
position: relative;
z-index: 1;
}
.media-card__badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 2;
}
Inside the card, 1 and 2 are all you need. From the outside, the whole card is a single layer. isolation: isolate has no visual side effects, which makes it the cleanest way to create a context on purpose.
Keep Global Layers at the Top of the Tree
Headers, modals, toasts, and overlays should render as close to body as possible, or in the top layer. If they're nested deep inside a component tree, they'll inherit whatever stacking contexts their ancestors create.
Common Pitfalls
- Bumping numbers blindly. If
10doesn't work,10000almost certainly won't either. Find the context. - Forgetting
position.z-indexon a non-positioned block element does nothing (flex and grid items are the exception). - Animations that leave a context behind. An element whose
transformoropacityis being animated forms a stacking context while the animation runs, and withanimation-fill-mode: forwardsit keeps one after the animation ends. A final keyframe oftranslateY(0)also still counts as a transform. If an entrance animation traps a dropdown, drop the fill mode or let the animation end at the element's normal styles. - Assuming
z-index: -1goes behind the parent. It goes behind the nearest stacking context's content, which may be much further up the tree. overflow: hiddenconfusion. Clipping is a separate problem from stacking. An element can be on top in the stacking order and still be clipped by an ancestor withoverflow: hidden.
Conclusion
z-index isn't a global number line. It's a ranking among siblings inside a stacking context, and contexts nest like sheets of paper in a pile. When a high z-index fails, it's because the element is trapped on a sheet that sits lower in the pile, often because an ancestor quietly created a context through opacity, transform, position: sticky, or a z-index of its own.
Debug by walking up the tree, fix at the level where the real comparison happens, and prevent the next bug with a named z-index scale, isolation: isolate on components, and the top layer for anything that needs to float above the whole page. Do that, and you'll never type 99999 again.


