The z-index property controls which elements appear on top when they overlap — a higher value sits above a lower one. It sounds simple, but z-index is the single most commonly misunderstood property in CSS, because it doesn't just compare numbers globally: it only works inside something called a stacking context, and a huge amount of "why isn't my z-index working" frustration comes from not understanding how those contexts are created.
z-index Only Works on Positioned Elements
The first rule almost everyone trips over: z-index has no effect on an element whose position is static (the default). It only applies to elements with position: relative, absolute, fixed, or sticky — see the position property for the full rundown of those values. Set a z-index on a static element and the browser silently ignores it.
.box {
z-index: 999; /* does absolutely nothing here */
}
.box.positioned {
position: relative; /* now z-index actually applies */
z-index: 999;
}
What a Stacking Context Actually Is
A stacking context is a self-contained "layer" of the page. Elements are stacked relative to their own stacking context, not the whole document. Once an element creates a new stacking context, every z-index inside it is only compared against its siblings within that same context — it can never climb above an element outside it, no matter how high you set the number.
A new stacking context is created by, among other things: the root <html> element, any positioned element with a z-index other than auto, an element with opacity less than 1, and an element with transform, filter, or will-change set.
The Classic Gotcha
Here's the scenario that confuses almost everyone at least once: a child element has z-index: 9999, yet it still appears behind another element elsewhere on the page with a much lower z-index.
<div class="parent-a">
<div class="child">I have z-index: 9999</div>
</div>
<div class="parent-b">Plain box with z-index: 1</div>
.parent-a {
position: relative;
z-index: 1; /* parent-a's own stacking context is low */
opacity: 0.99; /* also creates a stacking context! */
}
.child {
position: relative;
z-index: 9999; /* huge, but trapped inside .parent-a's context */
}
.parent-b {
position: relative;
z-index: 2; /* beats parent-a's context (1), so it wins overall */
}
Here, .child's z-index: 9999 is meaningless outside .parent-a — it's compared only against other children of .parent-a. What actually determines the outcome on screen is .parent-a itself competing against .parent-b, and since .parent-a has z-index: 1 versus .parent-b's 2, .parent-b wins and sits on top — regardless of the 9999 buried inside .parent-a.
The Fix
When a deeply nested element refuses to appear above something it logically should, the fix is almost never "set an even bigger z-index." Instead, trace upward and raise the z-index of the ancestor that actually creates the competing stacking context — or restructure the markup so the element isn't trapped inside a low-stacked parent in the first place. Removing an unnecessary opacity, transform, or filter from an ancestor can also resolve it, since each of those silently creates a new context too.
