CSSHow Stacking Contexts Work

How Stacking Contexts Work

A stacking context is a self-contained group of elements that get painted together, in a defined order, as a single unit relative to their surroundings. Once you understand that z-index values only ever compete against sibling values inside the same stacking context, a huge category of "why isn't my z-index working" bugs stops being mysterious.

What Creates a New Stacking Context

The root element always forms the outermost stacking context. Beyond that, a long list of CSS properties and values create a new one on the element they're applied to. The most common triggers:

Trigger

Notes

The root element

<html> always starts a context — the base of the whole stack.

position (relative/absolute/fixed/sticky) with z-index other than auto

The classic, most-taught trigger. Positioning alone is not enough — it needs an explicit z-index too.

Flex or grid item with z-index other than auto

You do not need position here — being a flex/grid child is sufficient.

opacity less than 1

Any value below 1, e.g. opacity: 0.99.

transform other than none

Any 2D or 3D transform function, even a no-op like translateZ(0).

filter other than none

Blurs, drop-shadows, etc.

backdrop-filter other than none

Frosted-glass effects.

perspective other than none

Used for 3D transform scenes.

will-change naming a property that itself would create one

e.g. will-change: transform or will-change: opacity.

isolation: isolate

Creates a context with no other visual side effect — useful purely for containment.

mix-blend-mode other than normal

Blend modes need an isolated compositing group to blend correctly.

contain: layout / paint / strict / content

CSS Containment also establishes a stacking context.

position: fixed or sticky (even without z-index, in most browsers)

Historically inconsistent; modern browsers generally treat these as context-creating.

CSS
/* Any of these creates a NEW stacking context on .box */
.box { position: relative; z-index: 0; }
.box { opacity: 0.99; }
.box { transform: translateZ(0); }
.box { filter: blur(0); }
.box { isolation: isolate; }
.box { will-change: transform; }
The Core Rule: z-index Only Compares Within a Context

This is the single most important idea on this page: z-index values are only ever compared against sibling elements that belong to the same stacking context. An element with z-index: 9999 cannot climb above an element in a sibling stacking context that has a lower z-index — because they are never compared directly. What gets compared is the context itself, as a whole, against its own siblings.

The classic broken example
Given the markup below, many developers expect the button with `z-index: 9999` to appear above the sidebar, because 9999 is a much bigger number than 2. It does not — because the button is trapped inside a stacking context (`.card`) that itself only has `z-index: 1`, while the sidebar's context has `z-index: 2`. The button's 9999 is compared only against its siblings inside `.card`; it never gets compared against anything in `.sidebar`.

CSS
.card {
  position: relative;
  z-index: 1; /* .card is now a stacking context */
}

.card .tooltip-button {
  position: absolute;
  z-index: 9999; /* huge number, but trapped inside .card's context */
}

.sidebar {
  position: relative;
  z-index: 2; /* .sidebar is also a stacking context */
}

/* Result: .sidebar (z-index: 2) paints above .card (z-index: 1)
   as WHOLE UNITS. The button's z-index: 9999 never gets compared
   against anything inside .sidebar — it's irrelevant outside .card. */

The fix is to stop the parent from trapping the child in a low-value context. Either raise the parent's z-index above the sibling's, or — often cleaner — remove the property that created the unwanted stacking context on the parent in the first place, or restructure so the button isn't nested inside a competing context at all (for example, rendering it in a portal at the top level, as many modal/ tooltip libraries do).

CSS
/* Fix option 1: raise the parent context's z-index */
.card { position: relative; z-index: 3; } /* now above .sidebar (2) */

/* Fix option 2: don't give .card its own stacking context at all */
.card { /* no position/z-index/transform here */ }
.card .tooltip-button {
  position: fixed; /* escape into the nearest ancestor context instead */
  z-index: 9999;
}
How Contexts Nest

Stacking contexts form a tree, mirroring the DOM. Inside any single context, its direct children are ordered by a fixed painting algorithm (roughly: background/borders of the context root, then descendants with negative z-index, then normal in-flow content, then descendants with z-index: auto or position: static that themselves create contexts, then positioned descendants with z-index: 0 or auto, then finally descendants with a positive z-index — from lowest to highest). Once an element creates its own context, everything inside it is painted as a single atomic layer at whatever position that context earns among its own siblings.

  • A context can only be compared to its own siblings — never to a cousin several levels removed in a different branch.

  • Nesting is unlimited: a context can contain child contexts, which can contain their own child contexts.

  • Raising a deeply nested element's z-index can never let it escape its ancestor contexts — you have to fix the stacking at the level where the conflict actually happens.

Debugging tip
When z-index seems broken, use DevTools to check the computed style of every ancestor between the element and the <body> for `position`, `opacity`, `transform`, `filter`, and `will-change`. The first ancestor that creates a context is where the real competition is happening — not necessarily the element you're staring at.
Next
Float and clear: [Float & Clear](/css/float-clear).