CSS Stacking Contexts: Why z-index: 9999 Doesn't Work
z-index isn't a global ranking. It only compares elements inside the same stacking context, and a dozen properties quietly create new ones. Learn the painting order, find the context that traps your element, and fix overlap bugs for good.
On this page
It's one of the most common CSS bugs there is. The modal has z-index: 9999. The site header has z-index: 10. And the header is drawn on top of the modal. You try 99999, then !important; nothing changes.
The browser isn't broken. z-index is not a global ranking of every element on the page. It only orders elements within the same stacking context, and your modal is trapped inside a context that, as a whole, sits below the header. This article explains how the browser decides what's on top, what creates those contexts (often by accident), and how to find and fix the one causing your bug. It assumes you're comfortable with the cascade (The CSS Cascade and Specificity) and with positioning.
The bug#
<header class="site-header">…</header>
<main class="page">
<div class="modal">…</div>
</main>.site-header { position: sticky; top: 0; z-index: 10; }
.page { position: relative; z-index: 1; }
.modal { position: fixed; inset: 0; z-index: 9999; }The modal is drawn underneath the header. To see why, we need two ideas: the painting order inside one stacking context, and how contexts nest.
Painting order inside a stacking context#
A stacking context is a group of elements that the browser paints together, as a unit. Inside one, it paints in this order, from bottom to top:
- The background and borders of the element that forms the context
- Positioned descendants with a negative
z-index(most negative first) - Normal block boxes, in document order
- Floats
- Inline content: text, images, inline-blocks
- Positioned descendants with
z-index: autoor0, in document order - Positioned descendants with a positive
z-index, lowest first
Two consequences surprise people. First, positioned elements paint above non-positioned ones even without any z-index: adding position: relative alone can bring an element to the front. Second, z-index only has an effect on positioned elements (relative, absolute, fixed, sticky) and on flex and grid items. On a plain static block it's ignored.
Contexts nest like version numbers#
Here's the crucial rule: a stacking context is painted as a single layer inside its parent context, at the position given by its own z-index. Everything inside it is drawn within that layer, so a child can never escape its parent's position, however high its own z-index is.
A good mental model is version numbers. In our example, the root context contains two layers, the header (10) and .page (1). The modal's 9999 only matters inside .page, so its effective position is something like 1.9999:
The browser first compares 10 with 1 at the root level, and the header wins. The 9999 is never compared with the header at all. That's the whole bug.
What creates a stacking context#
This is the list that matters, because most stacking contexts are created by accident, as a side effect of a property added for a different reason:
| Property | Creates a context when… |
|---|---|
position: relative / absolute |
z-index is anything other than auto |
position: fixed / sticky |
always |
| flex or grid item | z-index is anything other than auto |
opacity |
less than 1 |
transform, scale, rotate, translate |
anything other than none |
filter, backdrop-filter |
anything other than none |
clip-path, mask, perspective |
anything other than none |
mix-blend-mode |
anything other than normal |
isolation |
isolate |
will-change |
names one of the properties above |
contain |
layout, paint, strict or content |
container-type |
size or inline-size |
Plus the root element (<html>), which is always one.
So a fade-in animation (opacity: 0.99 during the transition), a transform: translateZ(0) "performance hack", or a will-change: transform on a wrapper can each trap every z-index inside it. Classic real-world causes:
- An animated page wrapper with
transform, which traps every modal and dropdown inside it. - A sticky header in one part of the page and a dropdown inside a different sticky section.
- A card with
opacity: 0.95whose tooltip slides under the next card.
transform also breaks position: fixed
An ancestor with transform, filter, perspective or will-change: transform doesn't just create a stacking context: it also becomes the containing block for position: fixed descendants. Your "fixed" modal then positions itself relative to that ancestor instead of the viewport, and scrolls with it. If a fixed element is in the wrong place and in the wrong layer, look for a transform.
Finding the context that traps you#
DevTools don't show stacking contexts directly, but you can find them with a few lines of JavaScript. Select the misbehaving element in the Elements panel, then paste this into the console ($0 is the selected element):
function stackingContexts(el) {
const found = [];
for (let node = el.parentElement; node; node = node.parentElement) {
const s = getComputedStyle(node);
const parent = node.parentElement && getComputedStyle(node.parentElement);
const reasons = [];
const flexOrGridItem = /flex|grid/.test(parent?.display ?? "");
if (s.zIndex !== "auto" && (s.position !== "static" || flexOrGridItem))
reasons.push(`z-index: ${s.zIndex}`);
if (s.position === "fixed" || s.position === "sticky") reasons.push(`position: ${s.position}`);
if (s.opacity !== "1") reasons.push(`opacity: ${s.opacity}`);
for (const prop of ["transform", "scale", "rotate", "translate", "filter", "backdropFilter", "perspective", "clipPath"])
if (s[prop] && s[prop] !== "none") reasons.push(prop);
if (s.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${s.mixBlendMode}`);
if (s.isolation === "isolate") reasons.push("isolation: isolate");
if (/transform|opacity|filter/.test(s.willChange)) reasons.push(`will-change: ${s.willChange}`);
if (reasons.length) found.push({ element: node, reasons: reasons.join(", ") });
}
return found;
}
console.table(stackingContexts($0));It walks up from the element and lists every ancestor that forms a stacking context, with the reason. It covers the common causes rather than every rule in the table. The first row is the context your z-index is trapped in; compare the z-index of the element you're fighting with against that ancestor's, not against your own.
Fixing it#
Once you know which context traps the element, pick the fix that matches the cause:
1. Remove the accidental context. If .page doesn't actually need z-index: 1 (or that transform, or will-change), delete it. This is the best fix when the property was added "just in case".
2. Move the element out. Overlays like modals, toasts and dropdown menus are best rendered as direct children of <body>, outside every component's context. Most UI frameworks have a tool for it: React's createPortal, Vue's <Teleport>, Svelte's portal libraries.
3. Use the top layer. Modern browsers have a top layer that sits above the entire document, outside all stacking contexts. Elements get there through the platform's own APIs, so no z-index is needed at all:
<dialog id="confirm">
<p>Delete this file?</p>
<form method="dialog"><button>Cancel</button> <button value="ok">Delete</button></form>
</dialog>
<script>
document.getElementById("confirm").showModal(); // rendered in the top layer
</script><dialog> opened with showModal(), elements with the popover attribute, and fullscreen elements all render in the top layer, wherever they are in the DOM. showModal() also adds a ::backdrop, traps focus and closes on Esc, which are things a hand-made modal usually gets wrong.
4. Raise the whole context. If neither of the above works, change the z-index of the context root (.page), not the element inside it. Remember this moves everything inside .page along with it.
isolation: isolate, the useful kind of context#
Sometimes you want a stacking context. A common example is a decorative layer behind a card's content:
.card { position: relative; background: white; }
.card::before {
content: "";
position: absolute;
inset: -4px;
z-index: -1; /* behind the card's content... */
background: linear-gradient(135deg, violet, orange);
border-radius: inherit;
}Without a stacking context on .card, the ::before with z-index: -1 is painted in whatever context the card lives in, at step 2 of the painting order: below the card's own white background. The glow is hidden behind the card it was meant to frame, and could even slide behind the backgrounds of the card's ancestors.
Give the card its own context and the problem disappears:
.card { position: relative; background: white; isolation: isolate; }Now the ::before is painted inside the card's context: above the card's background, below its content. isolation: isolate exists for exactly this. It creates a context and does nothing else: no positioning, no z-index value to manage, no side effects.
A z-index scale for the rest
For elements that really do share a context, avoid an arms race of ever-bigger numbers. Define a small scale once, for example --z-dropdown: 10; --z-sticky: 20; --z-overlay: 30; --z-toast: 40;, and only use those values. If something needs to go between two levels, that's a design decision, not a number to make up.
Recap#
z-indexonly orders elements inside the same stacking context; contexts nest, so think in version numbers like 1.9999 < 10.- Within a context the painting order is: background, negative
z-index, blocks, floats, inline content,z-index: auto/0, positivez-index. z-indexonly works on positioned elements and flex/grid items.- Many properties create contexts as a side effect:
opacity< 1,transform,filter,will-change, fixed/sticky positioning and more. - Fix overlap bugs by removing accidental contexts, moving overlays to
<body>, or using the top layer (<dialog>,popover). Useisolation: isolatewhen you want a context on purpose.