Home / Articles / Layout /

layout

Stacking context: why z-index doesn't work

z-index only compares siblings within the same stacking context. Once you understand that rule, layering stops being mysterious.

What is a stacking context?

A stacking context is a three-dimensional conceptualization of HTML elements along the z-axis. Within a stacking context, child elements are stacked according to their z-index values. Crucially, a child's z-index only competes with siblings in the same stacking context — it can never escape its parent's context.

/* Parent creates a stacking context with z-index: 1 */
.card {
  position: relative;
  z-index: 1;
}

/* This z-index: 9999 is trapped inside .card's context */
/* It will NOT appear above a sibling of .card with z-index: 2 */
.card .tooltip {
  position: absolute;
  z-index: 9999;
}

This is why cranking up z-index to absurd values doesn't always fix layering issues. The problem is usually a stacking context boundary, not the number.

What creates a stacking context

Many CSS properties create new stacking contexts. Here are the most common triggers:

  • position: relative/absolute/fixed/sticky with a z-index value other than auto
  • opacity less than 1
  • transform, filter, backdrop-filter, or perspective with any value other than none
  • isolation: isolate
  • will-change specifying any of the above properties
  • mix-blend-mode other than normal
  • contain: layout or contain: paint
  • Flex/grid children with a z-index value other than auto
Flex and grid children can use z-index without position: relative. This creates a stacking context implicitly, which catches many developers off guard.

The isolation property

The isolation property exists specifically to create a stacking context without any visual side effects. Unlike opacity: 0.999 or transform: translateZ(0) hacks, isolation: isolate has no rendering impact:

/* Create a stacking context intentionally */
.component {
  isolation: isolate;
}

/* Now all z-index values inside .component are scoped */
/* They won't leak out and interfere with other components */
.component .overlay { z-index: 10; }
.component .content { z-index: 1; }

/* Other components are unaffected */
.other-component .dropdown { z-index: 5; }

Think of isolation: isolate as a z-index firewall. It contains the stacking order within a component boundary.

z-index management strategy

Instead of arbitrary numbers scattered across your codebase, define a z-index scale using custom properties. This makes the stacking order explicit and maintainable:

:root {
  --z-base: 0;
  --z-dropdown: 10;
  --z-sticky: 20;
  --z-overlay: 30;
  --z-modal: 40;
  --z-toast: 50;
}

.dropdown    { z-index: var(--z-dropdown); }
.site-header { z-index: var(--z-sticky); }
.overlay     { z-index: var(--z-overlay); }
.modal       { z-index: var(--z-modal); }
.toast       { z-index: var(--z-toast); }
Keep gaps between z-index values (10, 20, 30...) so you can insert new layers without renumbering everything.

Debugging stacking issues

When an element won't layer correctly, follow this debugging process:

  • Step 1: Check if the element has position set. Without it (outside flex/grid), z-index has no effect.
  • Step 2: Walk up the DOM tree and look for ancestors that create stacking contexts. Any ancestor with opacity, transform, filter, or z-index is a boundary.
  • Step 3: Compare the stacking contexts. The element's context must be a sibling of (or the same as) the context of the element you want to stack against.
  • Step 4: If an unwanted stacking context exists, consider removing the property that creates it, or restructure the DOM.
/* Common accidental stacking context triggers */
.card {
  /* Each of these creates a stacking context: */
  opacity: 0.99;              /* remove if unnecessary */
  transform: translate(0);    /* remove if unnecessary */
  filter: drop-shadow(0 0 0); /* use box-shadow instead */
  will-change: transform;     /* remove after animation */
}

Component-scoped stacking

The most maintainable approach isolates each component's stacking context, then manages only the top-level layers globally:

/* Every component is isolated */
.card,
.nav,
.sidebar,
.dialog {
  isolation: isolate;
}

/* Only top-level layers need global z-index */
.page-header {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
}

With this pattern, internal z-index values inside a component (like a card's hover overlay at z-index: 2) can never accidentally appear above the modal backdrop. The stacking context boundary guarantees containment.