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:
- Which parent stacking context is on top?
- 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, orcontainon any ancestor. - Check whether
isolation: isolatewas 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.