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.