Fixing z-index Stacking Context Bugs

The Problem

Elements render out of expected visual order despite explicit z-index declarations. A modal appears behind an animated card, or a tooltip is clipped by a parent container. The root cause is almost always unexpected stacking context creation.

A stacking context is a self-contained ordering unit: z-index values inside it are relative to each other, not to the rest of the document. Any z-index: 3 inside a stacking context will always be below any z-index: 1 outside it, no matter how large the number is.

Beyond just visual ordering, stacking contexts affect compositing. When a parent creates an unexpected stacking context — especially via properties that trigger Compositing and GPU Acceleration layer promotion — child elements get rasterized into the parent’s GPU texture, and the compositor must synchronously rebuild the layer tree if the stacking order changes mid-frame.

Properties That Create Stacking Contexts

Many CSS properties create stacking contexts implicitly. The ones most commonly encountered as bugs:

  • opacity with a value less than 1
  • Any transform value other than none
  • filter with a value other than none
  • will-change: transform (or any value that causes promotion)
  • isolation: isolate
  • position: fixed or position: sticky
  • mix-blend-mode with a value other than normal
  • Elements with contain: layout or contain: paint

Isolation Workflow

1. Enable visual layer mapping

DevTools → Rendering tab (Esc → Rendering) → enable Layer borders and Paint flashing. Blue borders indicate composited layer boundaries. Green overlays indicate regions being repainted. Elements that share a layer will be ordered by their stacking context within that layer’s paint record.

2. Find implicit stacking context triggers

Run this in the DevTools console to list elements with stacking context–creating properties:

[...document.querySelectorAll('*')].filter((el) => {
  const cs = getComputedStyle(el)
  return (
    parseFloat(cs.opacity) < 1 ||
    cs.filter !== 'none' ||
    cs.mixBlendMode !== 'normal' ||
    cs.isolation === 'isolate' ||
    cs.willChange !== 'auto' ||
    (cs.position !== 'static' && cs.zIndex !== 'auto')
  )
}).map((el) => ({ el, tag: el.tagName, class: el.className }))

Cross-reference the results with the DOM hierarchy of the misbehaving element. A parent with opacity: 0.99 (a common transition trick) or will-change: transform on a container that wraps both the modal and its intended-to-be-behind content is a frequent culprit.

3. Profile synchronous layer mutations

Record a 5-second Performance trace during scroll or interaction. Filter for UpdateLayerTree. A spike above 8ms indicates synchronous layer tree reconstruction — the compositor is rebuilding the layer tree because a stacking context changed mid-frame.

{
  "name": "UpdateLayerTree",
  "dur": 14200,
  "args": {
    "layerCount": 12,
    "mainThreadBlocked": true
  }
}

4. Cross-reference the promotion rules

Verify which specific property on which ancestor is triggering the unexpected stacking context. See Layer Promotion and Composition for the full list of promotion rules.

Remediation

Remove the unnecessary stacking context trigger. If the parent element has transform: translateZ(0) purely as a “GPU acceleration trick” on a static container, remove it. If it has opacity: 0.999 to work around a subpixel rendering bug, fix the underlying issue instead.

Flatten the DOM hierarchy. Avoid deeply nested wrapper <div>s that exist only to apply a single CSS property. Each one is a potential stacking context and a potential layer promotion trigger.

Use isolation: isolate intentionally. If you need to contain a stacking context explicitly (to keep a component’s z-index values from interacting with the rest of the page), use isolation: isolate deliberately rather than triggering it as a side effect of another property.

Framework-specific patterns:

  • React: Use React.Fragment or <> to avoid unnecessary wrapper <div> elements. Use React.memo to prevent re-renders of static overlays that would trigger layer tree updates.
  • Vue/Angular: Prefer v-show / [hidden] (which just toggles display) over v-if / *ngIf for frequently toggled overlays, to keep the layer tree stable across toggle cycles.
  • CSS-in-JS: Audit dynamic style injection during hydration. Generated class names that add transform or filter to static containers during SSR→client reconciliation force premature layer promotion.

Validation

After fixing:

  • Layer count stabilizes: the Layers panel should show no more promoted layers than necessary for the animations in play.
  • UpdateLayerTree duration drops to < 2ms in the Performance trace.
  • Visual order is correct across browsers; test on Chromium, WebKit (Safari), and Gecko (Firefox) since their compositing heuristics differ.

Core Web Vitals targets:

Metric Target
CLS < 0.1 (stacking context bugs often cause layout shift when fixed)
INP < 200ms
Frame rate during scroll/interaction 60fps, < 2% drops