Back to Blog

CSS Stacking Contexts & z-index Traps

CSS Stacking Contexts & z-index Traps

CSS Stacking Contexts & z-index Traps

At first glance, z-index looks like a simple global ranking system.

But it is not always.

That is why an element with z-index: 9999 can still appear below another element with z-index: 1.

The hidden rule is: z-index only competes inside the same stacking context.

The trap

Many developers assume z-index works like a single document-wide scoreboard:

9999 always beats 1

That assumption fails the moment one of the elements lives inside a different stacking context.

Once that happens, the browser compares the parent layers first, then the children inside each local context.

Scenario 1: Parent stacking context wins or loses first

Even if Element A has a huge z-index, it can still lose when its parent is lower in the overall stacking order.

Example:

<div class="panel-a">
  <div class="badge-a">A</div>
</div>

<div class="panel-b">
  <div class="badge-b">B</div>
</div>
.panel-a {
  position: relative;
  z-index: 1;
}

.panel-b {
  position: relative;
  z-index: 2;
}

.badge-a {
  position: absolute;
  z-index: 9999;
}

.badge-b {
  position: absolute;
  z-index: 1;
}

Even though .badge-a has the bigger number, .panel-b sits in a higher parent layer, so .badge-b renders above it.

Scenario 2: Transform, filter, or will-change creates a local layer

Properties like transform, filter, and will-change often create a new stacking context.

That means the element’s children stop competing with the rest of the page and start competing only inside that local context.

Example:

.card-a {
  transform: translate3d(0, 0, 0);
}

.tooltip-a {
  position: absolute;
  z-index: 9999;
}

If a sibling element lives outside .card-a, the browser may still place that sibling on top if its parent context ranks higher.

Common context creators in this category include:

transform: translate3d(0, 0, 0);
filter: opacity(0.99);
will-change: transform;

Scenario 3: Containment and isolation bound the children

contain: layout, contain: paint, and isolation: isolate also change how descendants participate in layering.

This is useful for component boundaries, but it surprises people when a child overlay no longer escapes its container.

Example:

.widget-a {
  contain: paint;
}

.popup-a {
  position: absolute;
  z-index: 9999;
}

The popup can still be huge inside .widget-a, but it cannot leap out and cover content from another stacking context outside the widget.

A simple mental model

Think in two steps:

  1. Which parent stacking context is on top?
  2. Inside that context, which child has the higher z-index?

If the answer to step 1 already favors the other element, the child’s large number does not matter.

Quick checklist when z-index feels broken

  • Check whether either element is inside a positioned parent with its own z-index.
  • Check for transform, filter, opacity, will-change, or contain on any ancestor.
  • Check whether isolation: isolate was added intentionally.
  • Inspect the parent chain before increasing numbers again.

Practical example

Suppose you are debugging a dropdown that disappears behind a sticky header.

The instinctive fix is often:

.dropdown {
  z-index: 999999;
}

If the dropdown still hides, that number is not the real problem.

The real issue is usually one of these:

  • the dropdown is trapped inside a lower parent context,
  • a transformed ancestor isolated it,
  • or the header lives in a higher context that wins before child numbers are compared.

Takeaway

z-index is not global.

It only makes sense inside the stacking context that owns the element, so when two layers fight, compare the parents first and the children second.