DevDockTools

How to Debug a CSS Stacking Context Issue (Step by Step)

z-index not working even at 9999? It's almost always a stacking context problem, not a z-index problem. Here's the systematic way to find and fix it.

By Daniel Agrici6 min read
cssz-indexstacking contextdebuggingweb development

The Symptom That Sends You Down the Wrong Path

You set z-index: 9999 on an element and it's still rendering behind something it shouldn't be. The instinct is to raise the number further, or add !important, or start scattering z-index values across unrelated elements hoping one of them fixes it. None of that works, because the problem usually isn't the z-index value — it's that the element is trapped inside a stacking context that itself is losing to another one, and no child-level z-index can escape its parent's context.

What a Stacking Context Actually Is

A stacking context is a self-contained layer of the render tree. Within one stacking context, z-index values compete normally. But once an element creates a new stacking context, everything inside it is painted as a single unit relative to sibling contexts — its children's z-index values only matter inside that context, never against elements outside it.

<div class="context-a" style="position: relative; z-index: 1;">
  <div class="child" style="position: relative; z-index: 9999;"></div>
</div>
<div class="context-b" style="position: relative; z-index: 2;"></div>

.child has z-index: 9999, but it will still render behind .context-b, because .context-a (z-index 1) loses to .context-b (z-index 2) at the parent level — and .child's enormous z-index only has authority within .context-a. This is the single most common cause of "z-index isn't working."

Systematic Debugging Steps

1. Confirm it's actually a stacking context problem

Temporarily set the "losing" element's z-index to an absurd value and its position to fixed on a completely isolated test to see if it can render above everything when unconstrained. If it can in isolation but not in place, the problem is context nesting, not the value itself.

2. Find what's creating a stacking context between the element and its target

Walk up the DOM from the misbehaving element, checking each ancestor for any property that triggers a new stacking context:

/* any of these create a new stacking context on the element they're applied to */
position: relative | absolute | fixed | sticky;  /* + z-index other than auto */
opacity: <1;
transform: <anything other than none>;
filter: <anything other than none>;
backdrop-filter: <anything other than none>;
will-change: transform | opacity | filter | ...;
isolation: isolate;
mix-blend-mode: <anything other than normal>;
contain: layout | paint | strict | content;

The full authoritative list is on MDN's stacking context reference. In practice, transform and opacity are the two most common accidental triggers — teams add a hover transition (transform: scale(1.02) or opacity: 0.9) to a card component without realizing it silently traps every z-index inside that card.

3. Use DevTools to visualize contexts directly

Chrome and Edge DevTools show stacking contexts explicitly: in the Elements panel, a small badge appears next to elements that establish one, and the Layers panel (More tools → Layers) gives a 3D exploded view of the actual paint order. This is faster than manually walking the DOM tree for anything beyond a shallow nesting depth.

// quick console check: does this element create a stacking context?
const el = document.querySelector('.suspect');
const style = getComputedStyle(el);
console.log({
  position: style.position,
  zIndex: style.zIndex,
  opacity: style.opacity,
  transform: style.transform,
  filter: style.filter,
  isolation: style.isolation,
  mixBlendMode: style.mixBlendMode,
});

Run this against each ancestor from the target element up to <body> and you'll usually spot the offending property within a few levels.

4. Fix at the right level, not the wrong one

Once you've found the ancestor creating an unwanted context boundary, you have three real fixes:

Remove or relocate the triggering property, if it's not load-bearing:

/* if this transform was just a hover polish, consider scoping it narrower */
.card:hover {
  transform: scale(1.02);
}

Raise the z-index at the context-creating level, not on the deeply nested child:

/* fix the ancestor's position in the stacking order, not the leaf element */
.context-a { z-index: 3; } /* now beats .context-b's z-index: 2 */

Move the element out of the trapping context entirely, using a portal pattern (common for modals/tooltips):

// React example: render outside the constrained DOM subtree
import { createPortal } from 'react-dom';

function Tooltip({ children }) {
  return createPortal(children, document.body);
}

Portals are the standard fix for modals and tooltips specifically because those components need to visually escape whatever stacking/overflow context their trigger button lives in — no amount of z-index tuning fixes a modal trapped inside a parent with overflow: hidden and its own stacking context.

Decision Point: Fix the Cascade, or Isolate Deliberately?

If the stacking context conflict is accidental (a hover transform nobody meant to have side effects), fix it by removing or scoping the trigger. If you actually want a component to be self-contained — say, a widget you don't want bleeding z-index conflicts into the rest of the page — create the context deliberately and document why:

.widget {
  isolation: isolate; /* deliberately contains all child z-index within this widget */
}

isolation: isolate is the cleanest tool for this because, unlike transform or opacity, it has zero visual or layout side effects — it exists purely to create a stacking context boundary. Reach for it instead of a throwaway transform: translateZ(0) hack, which was a common workaround before isolation had reliable support and still shows up in older codebases.

Preventing the Next One

  • Avoid unqualified high z-index values (z-index: 9999) as a first response — they mask the real issue and make future debugging harder because now there are multiple oversized values competing.
  • Establish a small z-index scale for your project (--z-dropdown: 10, --z-modal: 100, --z-toast: 200) so conflicts are predictable rather than an arms race.
  • When adding transform or opacity for a hover/transition effect, check whether the element has descendants relying on z-index reaching outside it — if so, scope the effect to a wrapper that doesn't need to participate in the outer stacking order.

If your debugging session also turns up an unrelated elevation/shadow inconsistency while you're in a component's CSS, the Box Shadow Generator is a fast way to standardize elevation values while you're already in there — worth doing in the same pass rather than reopening the file later.

Frequently Asked Questions

Why doesn't z-index: 9999 fix my element being behind another one?
z-index only resolves conflicts between siblings within the same stacking context. If the two elements belong to different stacking contexts, the parent contexts' stacking order determines the outcome first, and no child z-index value — however large — can override that.
What CSS properties create a new stacking context?
position with a z-index other than auto, opacity less than 1, transform, filter, will-change naming a triggering property, isolation: isolate, mix-blend-mode other than normal, and a handful of others. The full list is documented on MDN's stacking context page.
Does isolation: isolate affect layout?
No. isolation: isolate creates a new stacking context without any visual or layout side effects, which makes it the cleanest tool for deliberately containing z-index behavior compared to using transform or opacity as a workaround.