What you'll learn
Quick Answer
z-index only works on positioned elements and on flex or grid children, and it only compares elements inside the same stacking context. A transform, an opacity below one, a filter, will-change, position fixed or sticky, or isolation: isolate on any ancestor creates a new stacking context, and everything inside it is stacked as a single unit against its siblings. That is why a dropdown with z-index 9999 can still sit behind a card with z-index 1.
z-index is ignored on most elements
Before anything to do with stacking contexts, check the boring cause. z-index has no effect on an element whose position is static, which is the default for every element on the page.
/* does nothing */
.overlay { z-index: 10; }
/* works */
.overlay { position: relative; z-index: 10; }Devtools will not warn you. The declaration appears in the Styles panel, not struck through, looking entirely healthy. Engines differ on what they report back for the computed z-index here, some showing auto and some echoing the number you wrote, so do not use that as your test. If a z-index seems to be doing nothing at all, look at the computed position first.
position: relative with no offsets is the usual fix. It changes nothing visually, it does not remove the element from normal flow, and it makes z-index apply.
There is one exception worth knowing, because it looks like a contradiction. Flex and grid children honour z-index even when they are static.
.row { display: flex; }
.row > .badge { z-index: 2; } /* works, no position needed */Direct children of a flex or grid container are treated as if they were positioned for the purposes of painting order. Only direct children, though. A grandchild inside a flex item is back to needing position.
The other quiet detail is that z-index: auto and z-index: 0 look identical on screen but are not the same thing. Both place the element at level zero, but auto does not create a stacking context while 0 on a positioned element does. Adding what looks like a harmless z-index: 0 to a parent can therefore change how everything inside it stacks, which is exactly the trap the rest of this article is about.
What a stacking context actually is
The useful mental model is a sealed box.
When an element creates a stacking context, everything inside it is painted into that box. The z-index values of its descendants are compared only against each other, inside the box. The box as a whole then takes one position in its parent's stacking order, decided by the box's own z-index, and moves as a single unit.
Nothing inside the box can ever escape it. A child with z-index: 999999 is the topmost thing inside that box and still sits wherever the box sits. This is the entire explanation for one of the most commonly reported CSS bugs there is.
.card {
position: relative;
transform: translateY(-2px); /* creates a stacking context */
}
.card .dropdown {
position: absolute;
z-index: 9999; /* trapped inside .card */
}
.next-card {
position: relative;
z-index: 1;
}Read it as the browser does. .card has no z-index, so its box sits at level 0. .next-card sits at level 1. Level 1 paints above level 0, so .next-card covers .card and everything inside it, dropdown included. The 9999 was never compared to the 1. They were never in the same conversation.
The giveaway is the shape of the bug. If a huge z-index makes no difference at all, the element is not competing where you think. Raising the number further is wasted effort; the fix is to change which context it lives in.
The other consequence is that a stacking context isolates its children. That is occasionally exactly what you want, and CSS gives you a way to ask for it deliberately, covered below.
Everything that quietly creates one
Here is the list that matters day to day. An element creates a stacking context when any of the following is true.
- It is the root
<html>element. There is always one context. positionisrelativeorabsoluteandz-indexis anything other thanauto.positionisfixedorsticky. These create one regardless ofz-index.opacityis less than1. Even0.999.transform,scale,rotate,translate,perspective,filter,backdrop-filter,clip-pathormaskis set to anything other thannone.mix-blend-modeis anything other thannormal.isolation: isolate.will-changenames a property that would create one, such astransform,opacityorfilter.containincludespaint, or islayout,strictorcontent.- It is a flex or grid child with a
z-indexother thanauto.
The full specification list is longer, but these cover almost every real bug.
What makes this painful is that nearly all of them are things you add for appearance, with no expectation of touching stacking at all. A hover lift with transform: translateY(-2px). A fade-out with opacity: 0.9. A frosted header with backdrop-filter: blur(8px). A will-change: transform added to smooth an animation. Each of these silently seals the element, and the dropdown inside it stops being able to escape.
Animations are the sneakiest version, because the context only exists while the animation runs. An element with opacity animating from 0 to 1 has a stacking context for the duration and loses it at the end. A dropdown that is behind a card for a quarter of a second and then correct is almost always this.
The painting order when nothing has a z-index
Most elements on a page have no z-index at all, and they still overlap in a defined order. Knowing that order explains several bugs that have nothing to do with the property.
Within a single stacking context, the browser paints in roughly this sequence, back to front:
- The background and borders of the element that forms the context.
- Descendants with a negative
z-index. - Non-positioned block-level boxes in the normal flow.
- Floated boxes.
- Inline content, including text.
- Positioned elements with
z-index: autoor0, in source order. - Positioned elements with a positive
z-index, lowest first.
Two useful facts fall straight out of this.
Text paints above backgrounds without any help. Inline content sits at level 5 while plain block backgrounds are at level 3, which is why overlapping text is usually still readable even when nobody set a z-index.
Negative z-index goes behind in-flow content but not behind the context's own background. This is the pattern for a decorative shape behind text:
.hero { position: relative; }
.hero::before {
content: "";
position: absolute;
inset: 0;
background: url("/pattern.svg");
z-index: -1;
}Whether that works turns on one question: does .hero form a stacking context? position: relative on its own does not. If .hero does form one, through a z-index of its own, a transform or isolation: isolate, then the pseudo-element sits behind the text but still in front of the hero's own background, which is exactly what you want. If it does not, the negative z-index escapes upwards into the nearest ancestor context and paints behind the hero's background too, so the pattern vanishes completely. Adding isolation: isolate to .hero is what makes this predictable rather than accidental.
Source order is also the final tiebreaker at equal level. Two positioned siblings with the same z-index stack in document order, so the later one wins. Reordering the markup is sometimes the smallest correct fix.
How to debug and how to avoid it
When something is behind something else, resist raising the number. Work up the tree instead.
1. Confirm the element is positioned. Check the computed position, not the one you wrote. A framework or a utility class may have changed it.
2. Walk up the ancestors looking for a stacking context. This snippet does it in the console:
// start at the PARENT: the trapped element itself is not the culprit
let el = document.querySelector(".dropdown").parentElement;
while (el && el !== document.documentElement) {
const s = getComputedStyle(el);
const positioned = s.position === "relative" || s.position === "absolute";
const contained = ["paint", "layout", "strict", "content"]
.some((v) => s.contain.includes(v));
if (
s.transform !== "none" ||
s.filter !== "none" ||
s.backdropFilter !== "none" ||
s.clipPath !== "none" ||
s.opacity !== "1" ||
s.mixBlendMode !== "normal" ||
s.isolation === "isolate" ||
s.willChange !== "auto" ||
contained ||
s.position === "fixed" ||
s.position === "sticky" ||
(positioned && s.zIndex !== "auto")
) {
console.log("stacking context:", el, {
position: s.position,
zIndex: s.zIndex,
transform: s.transform,
opacity: s.opacity,
filter: s.filter
});
}
el = el.parentElement;
}The first element it logs is the nearest ancestor context, which is your culprit. It deliberately over-reports a little: a will-change naming a harmless property such as color will show up even though it creates no context, and that is the right trade when you are hunting. Chrome DevTools also has a Layers panel and a 3D View utility that render the stack visually, which is faster once you know what you are looking at.
3. Fix the context, not the number. Three options, in order of preference. Remove the property that created the context if it is not doing real work, for example a will-change left behind after an animation. Raise the container instead of the child, since lifting the whole box lifts everything inside it. Or move the element out of the container entirely, which is what a React portal or the modern <dialog> element and the top layer are for.
To avoid the problem in the first place: use a small set of named levels rather than arbitrary numbers, so nobody has to guess.
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}Add isolation: isolate deliberately on major layout sections so that stacking inside a component can never leak out. And render overlays at the end of the body rather than nesting them deep inside cards, because the safest way to win a stacking fight is to not be in it.
