Articles

CSS Stacking Contexts and Z-Index Explained

Z-index only compares elements within the same stacking context. What creates a new context, how they nest, and why z-index: 9999 sometimes fails.

Takina Takina · · 4 min read
Close-up of CSS code on a screen

A stacking context is a self-contained layer of the page in which elements are painted back-to-front according to their z-index, and z-index values only ever compete against siblings inside the same stacking context — never across contexts. This is the single most common source of “why won’t my z-index: 9999 work” bugs: the element isn’t losing on z-index, it’s trapped inside a parent stacking context that itself sits below the element it should be covering.

What creates a stacking context

Every page has one root stacking context, formed by the <html> element. Beyond that, a new stacking context is created by any element that has:

  • A position of absolute or relative combined with a z-index other than auto
  • A position of fixed or sticky (z-index not required)
  • An opacity less than 1
  • A transform, filter, backdrop-filter, or perspective other than none
  • A will-change value naming a property that would itself create a stacking context
  • isolation: isolate
  • Flex or grid items with a z-index other than auto

Notice that most of these have nothing to do with layering — opacity and transform are common animation properties, so it’s easy to accidentally create a stacking context you weren’t thinking about. If you’re using CSS transitions or animations with transform or opacity, every animated element is quietly forming its own stacking context for the duration.

Why nesting traps z-index

Once an element creates a stacking context, everything inside it is painted as a single unit relative to its siblings. A child with z-index: 9999 can only out-rank its siblings within that context — it can never climb above an element outside the context, no matter how high its z-index goes, if the context itself paints below that outside element.

A concrete case: a modal with position: fixed; z-index: 1000 should sit above everything. But if an ancestor of the modal has transform: translateZ(0) (a common old trick to force GPU acceleration) and no explicit z-index, that ancestor forms its own stacking context painted in normal document order — and if a later sibling of that ancestor also creates a stacking context, it can paint above the modal regardless of the modal’s z-index. The fix is usually to render the modal via a portal at the document root, outside the problematic ancestor, or to remove the accidental stacking context.

Debugging a stacking bug

  1. Open browser devtools and look for a “Layers” or “3D view” panel — Chrome and Firefox both visualize stacking contexts directly, which is faster than reasoning about it by hand.
  2. Walk up the DOM from the misbehaving element and check every ancestor for the trigger properties listed above. The first ancestor that creates a stacking context is the ceiling that element’s z-index is trapped under.
  3. Check whether the ancestor itself has a z-index. If it does, compare it against its own siblings — the whole context moves as a block.
  4. If the element genuinely needs to escape, move it in the DOM (a portal, or rendering it as a direct child of <body>) rather than escalating z-index numbers, which just shifts the same bug elsewhere.

A minimal reproduction

The classic accidental-stacking-context bug is easy to reproduce with three sibling elements:

<div class="ancestor">
  <div class="modal">I should be on top</div>
</div>
<div class="later-sibling">I paint after .ancestor in DOM order</div>
.ancestor { transform: translateZ(0); } /* creates a stacking context, no z-index set */
.modal { position: fixed; z-index: 1000; }
.later-sibling { position: relative; z-index: 1; } /* creates its own context too */

Here, .modal is a child of .ancestor, and .ancestor has no explicit z-index of its own — so within the root stacking context, .ancestor’s entire painted layer, modal included, is compared against .later-sibling using normal paint order rather than the modal’s z-index of 1000. Since .later-sibling comes after .ancestor in the DOM, it paints on top, burying the modal despite its enormous z-index. The z-index of 1000 only ever competed within .ancestor’s own stacking context — it was never in the running against .later-sibling at all.

Isolation as an explicit tool

The isolation: isolate property exists purely to create a new stacking context without any of the side effects that come from properties like transform or opacity. It’s useful when you want to deliberately contain z-index competition to a component’s own subtree — for example, ensuring a component’s internal layering never leaks out to compete with unrelated elements elsewhere on the page — without also triggering a new containing block, GPU compositing layer, or visual change. Reaching for isolation: isolate when you want a stacking context on purpose, rather than relying on an incidental transform or opacity value, makes the intent visible in the stylesheet instead of hidden as a side effect of an unrelated property.

Stacking order without z-index

Even with no z-index anywhere, elements paint in a defined order: the root background first, then non-positioned block elements in DOM order, then floats, then inline content, then positioned elements (relative, absolute, fixed) in DOM order. This is why an absolutely positioned element with no z-index still appears above ordinary flow content — position alone changes paint order, before z-index is even considered. Combined with logical properties and container queries, layout code today rarely relies on this default order intentionally, but it explains a lot of “it worked without z-index” surprises.

The takeaway

Z-index comparisons never cross a stacking context boundary — an element can only out-rank its siblings within its own context, not elements sealed inside a sibling context. Properties like opacity, transform, and position: fixed create stacking contexts as a side effect, which is the usual cause of a high z-index silently losing. When debugging, use the browser’s layers panel to find which ancestor formed the boundary, and prefer moving the element in the DOM over raising z-index numbers indefinitely.

Takina Takina · · 4 min read

CSS object-fit and object-position, Explained

object-fit controls how an image or video is cropped inside its box, and object-position controls which part of it stays visible. How they work together.

#CSS #Web Development #Frontend
Takina Takina · · 5 min read

CSS inherit, initial, unset & revert Explained

CSS's four global keywords control where a property's value comes from. How inherit, initial, unset, and revert differ, with a comparison table.

#CSS #Web Development #Frontend
Takina Takina · · 4 min read

Dynamic Viewport Units: dvh, svh, and lvh Explained

dvh, svh, and lvh fix the classic mobile vh bug where browser toolbars cut off full-height layouts. Here's what each unit measures and when to use it.

#CSS #Web Development #Frontend