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. That rebuild is most painful during scroll or animation, so the fixes here pair with Scroll and Input Performance and Animation Performance Patterns.

This guide sits under Layer Promotion and Composition, part of Compositing and GPU Acceleration. The core failure mode is that a child’s z-index, no matter how large, is confined to the paint order of its own stacking context — a sibling context with a higher z-index always wins.

z-index is confined to its stacking context A child with z-index 9999 inside context A still paints below sibling element B, because B's context has a higher z-index than A. DOM and CSS setup Context A — z-index: 1 child .modal — z-index: 9999 Element B — z-index: 2 Actual paint order Element B (z:2) — on top Context A and child (z:1) Higher z-index wins between sibling contexts

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

Any one of these on an ancestor forms a new stacking context, which both traps the descendant z-index scale and — for the promotion-triggering members — hands the compositor an extra layer to raster and manage.

Triggers to stacking context to consequences Common CSS properties feed into a new stacking context, which traps child z-index and adds layer promotion cost. Common triggers opacity < 1 transform: not none filter: not none will-change: transform isolation: isolate position: fixed / sticky mix-blend-mode: not normal contain: layout / paint New stacking context formed on the element child z-index trapped layer promotion cost

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.

Flattening nested wrappers Before, three nested wrappers each create a stacking context; after, a single flattened element carries the modal with no stray contexts. Before — nested wrappers div.wrap — transform div.inner — opacity: 0.99 .modal — z-index: 9999 After — flattened .modal — z-index: 9999 one element, no stray contexts

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

Capture these with the observers and lab harness described in Rendering Performance Metrics and Tooling so a regression in layer count or frame rate is caught before it ships.

Frequently Asked Questions

Why does my z-index 9999 modal still render behind another element?

Because z-index is scoped to the stacking context that contains it. If the modal lives inside an ancestor that formed its own stacking context (via transform, opacity < 1, filter, and so on), that ancestor’s z-index is what competes with the sibling — the modal’s 9999 never leaves its box. Move the modal out of the offending ancestor, or raise the ancestor’s own z-index.

Does opacity 0.99 really create a stacking context?

Yes. Any opacity value strictly less than 1 forms a stacking context, so the common opacity: 0.99 subpixel workaround silently changes stacking and can also trigger layer promotion. Fix the underlying rendering issue instead of leaving the fractional opacity in place.

How do I create a stacking context on purpose without side effects?

Use isolation: isolate. It forms a stacking context without triggering a paint filter, a transform, or reduced opacity, so it contains a component’s z-index values without the compositing baggage of the other triggers.

Why does fixing a z-index bug sometimes spike UpdateLayerTree?

When the stacking order of a composited layer changes mid-frame, the compositor must reconstruct the layer tree synchronously on the main thread. A UpdateLayerTree duration above 8ms in a Performance trace is the tell. Stabilize which elements are promoted so the tree stops rebuilding every frame.

Do all browsers form stacking contexts identically?

The spec-defined triggers are consistent, but compositing heuristics that promote layers differ between Chromium, WebKit, and Gecko. Always verify visual order and layer count across all three, since a context that promotes to its own layer in one engine may stay merged in another.