Skip to story

Css Layout

Z-Index Is Not Magic

9 min read · May 31, 2026 ★ Flagship

Reading level

The modal that hides behind everything

You add a modal. It needs to appear on top. You give it z-index: 9999. It still appears behind the sidebar. You try z-index: 99999. Same problem. You try z-index: 999999999. Still behind.

This isn't about using a bigger number. The modal is trapped inside a stacking context — and no z-index value can escape it.

Z-index only works within the same stacking context. A child element can never stack above an element in a sibling stacking context, no matter how large its z-index value is. The bug is almost always: the modal's container has an unintentionally-created stacking context that confines the modal's stacking order.

Stacking contexts are created by over a dozen CSS properties — many not obviously related to layering. The fix is architectural, not numerical: render portals (modals, tooltips, dropdowns) as direct children of <body> or a known top-level container to escape ancestor stacking contexts entirely.

What creates a stacking context

A stacking context is like a "group" that gets stacked as a single unit. Everything inside the group is painted together, and the group's position relative to other groups is determined by the group's z-index — not the children's.

Common triggers that create a new stacking context:

  • position: relative/absolute/fixed/sticky + any z-index value (including 0)
  • opacity less than 1
  • transform (any value)
  • filter (any value)
  • will-change: transform or will-change: opacity
  • isolation: isolate

The tricky ones: transform: translateZ(0) is often added for GPU compositing ("hardware acceleration hack") — but it also creates a stacking context. If your animation container has this, every modal inside it is trapped.

will-change: transform (pre-promoting elements for animation performance) also creates a stacking context. So does any non-auto z-index on a positioned element.

The full list is in the CSS spec (CSS2 spec + CSS Transforms + CSS Filters). Worth knowing: contain: layout and contain: paint also create stacking contexts. The proliferation of stacking context triggers in modern CSS makes this class of bug increasingly common — particularly in component libraries that use transform-based animation and will-change for performance.

The accidental stacking context

/* ❌ This creates a stacking context that traps the modal */
.sidebar {
  transform: translateX(0); /* GPU compositing hack — creates stacking context */
}

.modal {
  position: fixed;
  z-index: 9999; /* trapped inside .sidebar's context — won't go above */
}

The transform: translateX(0) was added to fix a jank issue on a sidebar animation. It silently created a stacking context, trapping the modal behind everything in the sidebar's "group."

Another common culprit: setting z-index on a positioned ancestor to control internal layering between siblings — but inadvertently creating a stacking context that constrains all descendants:

/* ❌ z-index: 0 on the container creates a stacking context */
.card-container {
  position: relative;
  z-index: 0; /* "reset" — but actually creates a context */
}
.tooltip {
  position: absolute;
  z-index: 10; /* constrained within card-container's context */
}

The pattern that catches teams by surprise: opacity: 0.99 on a fade-out container. Non-1 opacity creates a stacking context. A modal inside a fade-out wrapper gets trapped during the transition. The workaround — use opacity: 1 and animate with GSAP's alpha, or remove the fade from the modal's ancestor — but the root fix is portal rendering.

Two clean fixes

Fix 1: render the modal outside the stacking context (portal)

// React: render modal directly in body, outside all ancestors
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal">{children}</div>,
    document.body // direct child of body — no ancestor stacking context
  );
}

Fix 2: use isolation: isolate to explicitly contain stacking

/* Make the stacking context intentional and documented */
.sidebar {
  isolation: isolate; /* explicit — creates context intentionally */
}
/* Modal is now in body, not inside .sidebar */

React portals render a component's DOM output at a different node in the tree while keeping it in the React component tree. This means props, context, and event bubbling still work — but the DOM output is at document.body level, escaping all stacking contexts.

In non-React code: the same pattern is manual — append the modal element to body via JavaScript rather than leaving it as a descendant of a positioned ancestor.

isolation: isolate is the semantic, intentional stacking context creator. Unlike transform: translateZ(0) or z-index: 0, it has no visual effect — it only creates the context. Use it to isolate component layers deliberately, and remove accidental context creators (transform, will-change) from containers that should be transparent to stacking.

Pattern at a glance

Annotated example: modal z-index — trapped in stacking context vs portal

❌ TRAPPED Z-INDEX

/* parent creates stacking context */
.sidebar {
  opacity: 0.99; /* < 1 triggers new context */
  /* or: transform: translateZ(0) */
}

/* child z-index is now scoped to .sidebar */
.modal {
  position: fixed;
  z-index: 9999; /* can't escape .sidebar */
}

New stacking context on parent traps child's z-index

✅ PORTAL TO DOCUMENT ROOT

// React: render outside all ancestor contexts
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal">
      {children}
    </div>,
    document.body // no ancestor stacking context
  );
}

Remove stacking context trigger or portal to document root

Try it: z-index trapped vs portal

Click "Open modal" in broken mode — the modal appears, but behind the accidental stacking context container. In fixed mode, the modal portal renders outside the container and appears correctly on top.

Inspect the DOM in DevTools. In broken mode, the modal div is inside the container with transform. In fixed mode, it's a direct child of body.

The stacking context tree is visible in DevTools → Layers panel — each context shows as a separate composited layer. The broken modal is a sublayer of its container; the portal modal is at the root context.

⚡ Interactive demo

Auditing stacking contexts

To find accidental stacking contexts: open DevTools → Elements panel, select the problem element, and look for any ancestor with transform, opacity < 1, filter, or will-change in the Computed styles tab.

The browser DevTools Layers panel (3D view) shows every composited layer — each stacking context is a separate tile. Elements that share a stacking context are on the same tile. A modal on a different tile from the content it should cover is the visual confirmation of a stacking context bug.

Design system principle: all overlay components (modals, tooltips, select dropdowns, datepickers, popovers) should either:

  1. Render in a portal at document.body level (best — escapes all contexts)
  2. Use a top-level overlay container with a documented z-index scale

The z-index scale pattern: define tokens --z-modal: 1000, --z-tooltip: 900, --z-overlay: 800. All overlay components use these tokens — teams don't reach for arbitrary large numbers.

References

Remember

Key takeaways

  • Z-index only competes within the same stacking context — no number is big enough to escape a parent context.
    transform, opacity < 1, filter, will-change, and isolation all create stacking contexts — many of them are side effects you didn't intend.
    Use isolation: isolate to create stacking contexts intentionally, and remove accidental context-creating properties from overlay ancestors.
  • For modals, tooltips, and dropdowns: render them in a portal at document.body level to escape all ancestor stacking contexts.
    React portals render DOM output at a different node while keeping the component in the React tree — props, context, and event bubbling still work.
    Define a z-index token scale (--z-modal, --z-tooltip) — teams shouldn't reach for arbitrary numbers, and the scale documents the intended layering hierarchy.

Enjoyed this case?

Case 2 of 2 in Css Layout · 12 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.