Skip to main content

Harshal V. LADHE

Mastering CSS z-index: Stacking Contexts, isolation, and Layering Bugs

Layer confidently with z-index, stacking contexts, and isolation.
Published at:
Last updated:
Estimated reading time:23 min read

You add z-index: 10 to a dropdown. It stays hidden. You try 100, then 999, then 9999. Still hidden. Somewhere in the codebase, an element with a modest z-index: 2 keeps painting on top of it, and no number seems big enough to win.

This is one of the most common frustrations in CSS, and it has nothing to do with the size of the number. z-index follows a small set of rules that are rarely taught together: which elements it applies to, the order the browser paints things in, and — most importantly — stacking contexts, the invisible groups that decide who is allowed to compete with whom.

In this guide, we'll build that model from the ground up:

  • How the browser layers elements without any z-index
  • What z-index does, and why it sometimes does nothing at all
  • Stacking contexts: what they are, what creates them, and why z-index: 9999 loses to 2
  • How negative z-index really behaves
  • isolation: isolate — the cleanest way to create a stacking context on purpose
  • A scalable z-index system for production code, plus the top layer, which skips the whole fight
  • A repeatable way to debug any layering bug

The Third Dimension of a Web Page

A web page looks flat, but the browser treats it as a stack of layers along a z-axis that points out of the screen, towards you. When two elements overlap, the one painted later ends up closer to you, covering the one painted earlier.

So every layering question is really a question about painting order: in what order does the browser paint these elements? z-index is one input to that order — but not the only one, and not the first one.

Painting Order Without z-index

Even with no z-index anywhere, the browser follows a fixed order. Two rules cover most everyday cases:

  1. Later in the source paints on top. Among similar elements, the one that comes later in the HTML covers the one that comes earlier.
  2. Positioned elements paint above non-positioned ones. An element with position set to relative, absolute, fixed, or sticky paints above ordinary in-flow content, no matter where it sits in the source.
<div class="box one">One</div>
<div class="box two">Two</div>
<div class="box three">Three</div>
.box {
  block-size: 4rem;
  margin-block-end: -2rem; /* pull each box up so it overlaps the one before */
  background: white;
}

.one,
.two {
  position: relative;
}

Boxes one and two are both positioned, so they follow source order: two covers one. Box three comes last in the source, yet box two still paints on top of it, because three isn't positioned.

The Full Painting Order

Those two rules are shortcuts for the complete algorithm. Within a single group (we'll name these groups shortly), the browser paints in seven layers, from the back to the front:

Visual Illustration

The seven painting layers of a stacking contextSeven stacked sheets, painted from bottom to top: 1, the background and borders of the element that formed the stacking context; 2, positioned descendants with negative z-index; 3, non-positioned block boxes in normal flow; 4, floats; 5, inline content such as text; 6, positioned descendants with z-index auto or 0, along with stacking contexts created by properties like opacity and transform; 7, positioned descendants with positive z-index. Within one layer, later elements in the source paint on top.painted later → closer to you1Background and bordersof the element that formed the context2Negative z-indexpositioned descendants, lowest value first3Block boxes in normal flownon-positioned, block-level descendants4Floatsnon-positioned floating descendants5Inline contenttext, inline images, inline-block boxes6z-index: auto or 0positioned elements + opacity/transform contexts7Positive z-indexpositioned descendants, lowest value firstwithin one layer, later elements in the source paint on top of earlier ones
  1. The background and borders of the element that owns the group
  2. Positioned descendants with a negative z-index, lowest value first
  3. Block-level boxes in normal flow (not positioned)
  4. Floats (not positioned)
  5. Inline content — text, inline images, and inline-block boxes
  6. Positioned descendants with z-index: auto or 0, in source order — along with stacking contexts created by properties such as opacity and transform
  7. Positioned descendants with a positive z-index, lowest value first

Notice where z-index fits in: it only moves elements into layer 2 (negative values) or layer 7 (positive values). Everything else is decided by the element's type and its position in the source.

The layers also explain a quirk you may have seen: when two ordinary blocks overlap, the text of the first block can show through the background of the second. Both backgrounds are painted in layer 3, before any text is painted in layer 5.

z-index Basics

The Syntax

.element {
  position: relative; /* required — see below */
  z-index: 1;
}

z-index accepts two kinds of values:

  • auto (the default) — the element paints on its group's base level, and it does not start a new group.
  • An integer — positive, negative, or zero. Higher values paint closer to you. Units and decimals aren't allowed, so z-index: 1.5 and z-index: 10px are invalid and ignored.

When two elements have the same z-index, the tie is broken by source order: the later element paints on top.

z-index Needs a Positioned Element

This is the first rule that trips people up: z-index has no effect on an element with position: static, which is the default for every element.

/* ❌ Ignored: the element isn't positioned */
.badge {
  z-index: 10;
}

/* ✅ Works */
.badge {
  position: relative;
  z-index: 10;
}

position: relative is the usual choice when you only need z-index, because it keeps the element exactly where it was in the layout.

The Exception: Flex and Grid Items

There's one important exception. Children of a flex or grid container respect z-index even when they're static.

.toolbar {
  display: flex;
}

.toolbar .active-tab {
  z-index: 1; /* works — no position needed */
}

This is handy for overlapping cards in a grid or tabs in a flex row, but it also explains a confusing situation: the same z-index rule can "work" in one component and "fail" in another, simply because one parent uses display: flex and the other doesn't.

z-index: auto vs. z-index: 0

On a positioned element, auto and 0 paint at the same level (layer 6 above). The difference is invisible until the element has children with their own z-index:

  • z-index: auto → the element's children compete with the rest of the page.
  • z-index: 0 → the element creates a new group, and its children compete only with each other.

That "new group" is a stacking context, and it's the key to everything that follows.

Stacking Contexts: The Key to Everything

A stacking context is a group of elements that the browser layers together, as one unit. Inside the group, z-index values sort the children. Outside the group, the whole group is placed as if it were a single element, using the z-index of the element that created it.

The <html> element is the root stacking context, so every page has at least one. Many properties create more, nested inside it.

The Version-Number Mental Model

The easiest way to reason about nested stacking contexts is to think of them as version numbers.

Consider this page:

<header class="site-header">…</header>

<main class="content">
  <div class="modal">…</div>
</main>
.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
}

.content {
  position: relative;
  z-index: 1;
}

.modal {
  position: fixed;
  inset: 0;
  z-index: 9999;
}

The modal has z-index: 9999, yet the sticky header paints on top of it. Here's why:

  • .content has position: relative and z-index: 1, so it creates a stacking context.
  • The modal lives inside .content, so its 9999 only ranks it inside that context. Its effective position is 1.9999 — "version 1, patch 9999".
  • At the root, the browser compares only .site-header (10) and .content (1). Since 10 > 1, the header paints over all of .content — including the modal.

Visual Illustration

Nested stacking contexts as version numbersA document tree. The html element is the root stacking context. It has two children: a sticky header with z-index 10, and a main element with position relative and z-index 1. Inside main sits a fixed modal with z-index 9999. A dashed outline marks the stacking context of main, drawn around main and the modal. The effective position of the modal is 1.9999, so at the root the header at 10 beats main at 1, and the header paints over the modal.main's stacking context<html>root stacking context<header>position: sticky; z-index: 1010<main>position: relative; z-index: 11<div class="modal">position: fixed; z-index: 99991.9999At the root, only 10 and 1 are compared — so the header paints over all of <main>,including the modal, whose 9999 really means 1.9999.

No value of the modal's z-index can fix this. 1.99999999 is still less than 10.

See It for Yourself

The Stacking contexts tab of the playground further down is real HTML and CSS, so the browser decides what paints on top. It starts in the broken state: a tooltip with z-index: 9999 hidden under Card B, which has z-index: 2.

Open it and try these, one at a time:

  1. Drag the tooltip's z-index anywhere between 0 and 9999. Nothing changes — it's trapped inside Card A.
  2. Set Card B's z-index to 0 (or Card A's to 3). Now Card A outranks Card B, and the tooltip comes along with it. Try 1 as well: the two cards tie, so Card B wins again simply because it comes later in the source.
  3. Put both cards back to their starting values (Card A 1, Card B 2), then set Card A's z-index to auto. Card A no longer creates a stacking context, so the tooltip competes directly with Card B — and 9999 wins.
  4. Keep Card A at auto, and pick a value from Card A also has. Each one quietly creates a stacking context, and the tooltip is trapped again.

Step 4 is the one that catches experienced developers: opacity, transform, filter, and will-change all create stacking contexts, even when they don't visibly change anything. A fade-in effect or a "GPU acceleration" hack can break a dropdown's layering three components away.

What Creates a Stacking Context

Here are the common triggers. Any one of them is enough.

TriggerExample
The root element<html>
position: relative or absolute with a z-index other than autoposition: relative; z-index: 0;
position: fixed or sticky (always, with any z-index)position: sticky; top: 0;
A flex or grid item with a z-index other than autoz-index: 1; on a child of display: flex
opacity less than 1opacity: 0.99;
transform, translate, rotate, scale other than nonetransform: translateZ(0);
filter or backdrop-filter other than nonefilter: blur(0);
perspective, clip-path, or mask other than noneclip-path: inset(0);
mix-blend-mode other than normalmix-blend-mode: multiply;
isolation: isolateisolation: isolate;
will-change naming any property abovewill-change: transform;
contain including layout or paint (or strict/content)contain: paint;
container-type: size or inline-sizecontainer-type: inline-size;
view-transition-name other than noneview-transition-name: hero;
Elements in the top layer, and their ::backdrop<dialog> opened with showModal()

Two entries deserve a special mention:

  • position: sticky and position: fixed always create a stacking context. A sticky header is therefore a sealed group — any dropdown inside it can only be as high as the header itself.
  • container-type is new enough that many developers don't expect it here. Turning a card into a container query container also seals its layering.

Negative z-index

A negative z-index places an element in layer 2 of its stacking context: above the context's own background, but below everything else in it, including normal text.

The surprise is the phrase "its stacking context". If the element's parent doesn't create one, the negative element isn't ranked inside the parent at all — it falls back to the nearest ancestor that does, and slides behind the parent's background.

.parent {
  position: relative;
  background: white;
}

.parent .decoration {
  position: absolute;
  z-index: -1; /* goes behind .parent's white background! */
}

To watch it happen, open the Negative z-index tab of the playground and switch the parent between its three settings.

With z-index: auto, the red box hides behind the parent and only peeks out where it extends past the parent's edge. Once the parent creates a stacking context — with z-index: 0 or isolation: isolate — z-index: -1 means "the bottom of this parent's stack", and the box lands exactly where decorative layers usually belong: above the parent's background, below its content.

This pattern is the standard way to build decorative shapes, glows, and pseudo-element backgrounds:

.card {
  position: relative;
  isolation: isolate; /* contain the negative layer */
  background: var(--surface);
}

.card::before {
  content: "";
  position: absolute;
  inset: -0.5rem;
  z-index: -1;
  background: linear-gradient(135deg, #ff6a00, #ee0979);
  filter: blur(1rem);
}

isolation: isolate — Stacking Contexts on Purpose

In the last two sections, we kept needing to create a stacking context without changing anything else about an element. Developers have long done this with hacks, each with a side effect:

HackSide effect
position: relative; z-index: 0;Becomes the containing block for absolutely positioned descendants
transform: translateZ(0);Traps position: fixed descendants; may promote a GPU layer and blur text
opacity: 0.99;Slightly changes the element's appearance; confusing to future readers
will-change: transform;Reserves GPU memory; also traps position: fixed descendants

isolation exists to do this one job cleanly:

.component {
  isolation: isolate;
}

It accepts only two values: auto (the default) and isolate. With isolate, the element creates a stacking context and does nothing else. It doesn't need position or z-index, it doesn't change the containing block of any descendant, it doesn't promote a GPU layer, and it reads as exactly what it is: "this component's layering stays inside this component".

It's also safe to use everywhere: Chrome, Firefox, and Safari have supported isolation for over a decade, and Edge has supported it since its move to Chromium.

Use Case 1: Self-Contained Components

The most valuable use of isolation is making components layering-safe. Inside an isolated component, you can use small, local z-index values without worrying about the rest of the page:

.product-card {
  display: grid;
  position: relative;
  isolation: isolate;
}

.product-card__image   { z-index: 1; }
.product-card__overlay { position: absolute; inset: 0; z-index: 2; }
.product-card__badge   { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 3; }

That badge's z-index: 3 will never fight a site-wide header or modal, because it's only compared with the image and overlay inside the same card. Your layering decisions become local, just like scoped styles. (The image is a grid item, which is why it doesn't need its own position.)

Use Case 2: Containing mix-blend-mode

isolation was originally designed for blending. An element with mix-blend-mode blends with everything painted beneath it in its stacking context — not just its siblings. If you want a group of elements to blend only with each other, isolate the group. The Isolation & blending tab of the playground shows the difference with three overlapping circles over light and dark stripes.

With isolation: auto, each circle blends with the stripes behind the group, so its colours change from stripe to stripe. With isolate, the circles blend only with each other, and the finished group is laid over the stripes normally. The same applies to blended logos, image overlays, and text effects built with mix-blend-mode.

One Detail Worth Knowing

An element that creates a stacking context through isolation (or opacity, transform, and the like) is painted in layer 6 — the same level as a positioned element with z-index: 0. In rare layouts where an isolated element overlaps following non-positioned siblings, for example via a negative margin, it will now paint on top of them. If that happens, give the sibling position: relative so the two are ordered by source order again.

Try It Yourself

Every demo from the sections above lives in one playground, with a tab for each:

  • Stacking contexts: the tooltip trapped inside Card A. Change both cards' z-index, drag the tooltip's own value, and add the everyday properties that quietly create a stacking context.
  • Negative z-index: a decoration with z-index: -1. Switch its parent between z-index: auto, z-index: 0, and isolation: isolate to see where it lands.
  • Isolation & blending: three mix-blend-mode circles over light and dark stripes, with and without isolation: isolate on their group.

Under each demo, a readout explains why the browser painted what you see, and the panel below it shows the CSS for exactly that state. The rules behind every demo live in styles.css, so you can edit them too and watch the preview change — the readout only follows the controls.

z-index and Stacking Contexts Playground

Three tabs of real HTML and CSS: free a z-index: 9999 tooltip trapped inside its card, watch where a z-index: -1 decoration lands as its parent gains a stacking context, and keep mix-blend-mode inside a group with isolation: isolate — each with a readout explaining what the browser painted and the CSS for exactly that state.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>z-index and Stacking Contexts Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>z-index and Stacking Contexts</h1>
        <p>Real HTML and real CSS, so the browser decides what paints on top. Change the controls, then read why it happened and copy the CSS for exactly what you see.</p>
      </header>

      <div class="tabs" role="tablist" aria-label="Demo" id="demo-switch">
        <button class="tab tab--active" type="button" data-mode="stacking" role="tab" aria-selected="true">Stacking contexts</button>
        <button class="tab" type="button" data-mode="negative" role="tab" aria-selected="false">Negative z-index</button>
        <button class="tab" type="button" data-mode="blend" role="tab" aria-selected="false">Isolation &amp; blending</button>
      </div>

      <!-- Stacking contexts: can the tooltip escape Card A? -->
      <div id="stacking-demo" class="tab-panel tab-panel--active">
        <section class="demo-area" aria-label="Stacking context preview">
          <div class="stage stage--plain" aria-hidden="true">
            <div class="card card-a" id="card-a">
              <strong>Card A</strong>
              <code id="card-a-code">z-index: 1</code>
              <div class="tooltip" id="tooltip">
                Tooltip
                <code id="tooltip-code">z-index: 9999</code>
              </div>
            </div>
            <div class="card card-b" id="card-b">
              <strong>Card B</strong>
              <code id="card-b-code">z-index: 2</code>
            </div>
          </div>
        </section>

        <fieldset class="control-group">
          <legend>Controls</legend>
          <div class="fields-grid">
            <div class="field">
              <label for="card-a-z">Card A z-index</label>
              <select id="card-a-z">
                <option value="auto">auto</option>
                <option value="0">0</option>
                <option value="1" selected>1</option>
                <option value="2">2</option>
                <option value="3">3</option>
              </select>
            </div>

            <div class="field">
              <label for="card-a-trigger">Card A also has</label>
              <select id="card-a-trigger">
                <option value="none" selected>nothing</option>
                <option value="opacity">opacity: 0.99</option>
                <option value="transform">transform: translateZ(0)</option>
                <option value="filter">filter: blur(0)</option>
                <option value="will-change">will-change: transform</option>
                <option value="isolation">isolation: isolate</option>
              </select>
            </div>

            <div class="field">
              <label for="tooltip-z">Tooltip z-index (inside Card A)</label>
              <div class="range-row">
                <span class="range-edge">0</span>
                <div class="range-track">
                  <input type="range" id="tooltip-z" min="0" max="9999" step="1" value="9999">
                  <output class="range-bubble" id="tooltip-z-value" for="tooltip-z">9999</output>
                </div>
                <span class="range-edge">9999</span>
              </div>
            </div>

            <div class="field">
              <label for="card-b-z">Card B z-index</label>
              <select id="card-b-z">
                <option value="auto">auto</option>
                <option value="0">0</option>
                <option value="1">1</option>
                <option value="2" selected>2</option>
                <option value="3">3</option>
              </select>
            </div>
          </div>
        </fieldset>

        <output class="readout verdict" id="stacking-verdict" aria-live="polite"></output>
        <output class="readout" id="stacking-css"></output>
      </div>

      <!-- Negative z-index: where does z-index: -1 go? -->
      <div id="negative-demo" class="tab-panel">
        <section class="demo-area" aria-label="Negative z-index preview">
          <div class="stage stage--hatched" aria-hidden="true">
            <div class="parent" id="parent" data-context="auto">
              <strong>Parent</strong> — this text is in-flow content, so it always paints above a negative layer of the
              same stacking context.
              <div class="decoration">z-index: -1</div>
            </div>
            <div class="spacer"></div>
          </div>
        </section>

        <fieldset class="control-group">
          <legend>Controls</legend>
          <div class="field">
            <label for="parent-context">Parent also has</label>
            <select id="parent-context">
              <option value="auto" selected>z-index: auto</option>
              <option value="zero">z-index: 0</option>
              <option value="isolate">isolation: isolate</option>
            </select>
          </div>
        </fieldset>

        <output class="readout verdict" id="negative-verdict" aria-live="polite"></output>
        <output class="readout" id="negative-css"></output>
      </div>

      <!-- Isolation and blending: keeping mix-blend-mode inside a group -->
      <div id="blend-demo" class="tab-panel">
        <section class="demo-area" aria-label="Isolation and blending preview">
          <div class="stage stage--bands" aria-hidden="true">
            <div class="group" id="group">
              <div class="circle circle--1"></div>
              <div class="circle circle--2"></div>
              <div class="circle circle--3"></div>
            </div>
          </div>
        </section>

        <fieldset class="control-group">
          <legend>Controls</legend>
          <div class="fields-grid">
            <div class="field">
              <label for="group-isolation">Group isolation</label>
              <select id="group-isolation">
                <option value="auto" selected>auto</option>
                <option value="isolate">isolate</option>
              </select>
            </div>

            <div class="field">
              <label for="blend-mode">Circles' mix-blend-mode</label>
              <select id="blend-mode">
                <option value="multiply" selected>multiply</option>
                <option value="screen">screen</option>
                <option value="difference">difference</option>
              </select>
            </div>
          </div>
        </fieldset>

        <output class="readout verdict" id="blend-verdict" aria-live="polite"></output>
        <output class="readout" id="blend-css"></output>
      </div>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Ln –, Col –HTML6.7 KBUTF-8

Starting sandbox…

No console output yet.

No original version of /index.html to compare against.

Escaping the Fight: The Top Layer

Modern HTML has a separate layer that sits above the entire document, regardless of any z-index or stacking context: the top layer.

Elements are placed in the top layer when you:

  • Open a <dialog> with showModal()
  • Show an element with the popover attribute
  • Make an element fullscreen with requestFullscreen()
<button popovertarget="account-menu">Account</button>

<div id="account-menu" popover>
  <a href="/profile">Profile</a>
  <a href="/sign-out">Sign out</a>
</div>
const dialog = document.querySelector("dialog");
dialog.showModal(); // rendered above everything, with a ::backdrop

A top-layer element escapes every ancestor's stacking context — and overflow: hidden clipping, too. Within the top layer, elements stack in the order they were opened, so a popover opened from inside a modal dialog correctly appears above it. z-index has no effect on this order.

A z-index System for Production

On a real project, the worst z-index problems aren't the stacking contexts — they're the numbers. One developer writes 999, the next writes 9999 to beat it, and a year later nobody knows what 99999 is guarding against.

The fix is the same as for colours and spacing: a named scale.

Global Layers with Custom Properties

:root {
  --z-below: -1;
  --z-base: 0;
  --z-raised: 1;
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-overlay: 300;
  --z-modal: 400;
  --z-toast: 500;
  --z-tooltip: 600;
}

.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
}

.toast-region {
  position: fixed;
  z-index: var(--z-toast);
}

A few principles make the scale hold up:

  • Leave gaps (100, 200, …) so a new layer can slot in between two existing ones without renumbering.
  • Name layers by purpose, not by value. var(--z-modal) tells the next developer why; 400 doesn't.
  • Keep the global scale short. It should only cover layers that compete across the whole page: sticky headers, overlays, modals, toasts, and tooltips.
  • Use local values inside isolated components. A card's internal z-index: 1/2/3 never needs a global token.

The Same Scale in SCSS

If your project uses Sass, a map with a guard function catches typos at build time:

@use "sass:map";

$z-layers: (
  "below": -1,
  "base": 0,
  "raised": 1,
  "dropdown": 100,
  "sticky": 200,
  "overlay": 300,
  "modal": 400,
  "toast": 500,
  "tooltip": 600
);

@function z($layer) {
  @if not map.has-key($z-layers, $layer) {
    @error "Unknown z-index layer '#{$layer}'.";
  }

  @return map.get($z-layers, $layer);
}

.site-header {
  position: sticky;
  top: 0;
  z-index: z("sticky");
}

Debugging z-index Problems

When an element refuses to appear on top, guessing bigger numbers wastes time. Work through these steps instead.

A Step-by-Step Checklist

  1. Is the z-index applied at all? In DevTools, check the element's Computed tab. If position is static and the element isn't a flex or grid item, z-index is ignored.
  2. Find the stacking contexts above it. Walk up the element's ancestors and note every one that creates a stacking context (see the table above, or use the snippet below).
  3. Find the shared context. Do the same for the element that's covering yours. The closest stacking context the two have in common is where the real comparison happens.
  4. Compare the representatives. In that shared context, compare the two ancestors that represent each side — not the elements themselves. That comparison is the one you need to change.
  5. Rule out clipping. If part of the element is cut off rather than covered, it's an overflow problem, not a z-index problem. No z-index can paint outside an ancestor's overflow: hidden when that ancestor contains the element's containing block.

Then fix the cause at the right level:

  • Raise (or lower) the ancestor that represents the element in the shared context.
  • Remove an accidental stacking context, such as a leftover transform or will-change.
  • Move the element out of the trapping context — with the top layer or a portal.
  • Add isolation: isolate where a component's internal layering is leaking out.

A DevTools Helper

Browsers don't list stacking contexts directly, so here's a small snippet that does. Paste it into the DevTools console, select the problem element in the Elements panel, then run stackingContextsAbove($0):

function stackingContextsAbove(element) {
  const results = [];
  const paintProps = ["transform", "translate", "rotate", "scale", "filter", "backdropFilter", "perspective", "clipPath", "maskImage"];

  for (let node = element.parentElement; node; node = node.parentElement) {
    const style = getComputedStyle(node);
    const parentDisplay = node.parentElement ? getComputedStyle(node.parentElement).display : "";
    const isFlexOrGridItem = /flex|grid/.test(parentDisplay);
    const reasons = [];

    if (node === document.documentElement) reasons.push("root element");
    if (style.zIndex !== "auto" && (style.position !== "static" || isFlexOrGridItem)) reasons.push(`z-index: ${style.zIndex}`);
    if (style.position === "fixed" || style.position === "sticky") reasons.push(`position: ${style.position}`);
    if (Number(style.opacity) < 1) reasons.push(`opacity: ${style.opacity}`);
    for (const prop of paintProps) {
      if (style[prop] && style[prop] !== "none") reasons.push(`${prop}: ${style[prop]}`);
    }
    if (style.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${style.mixBlendMode}`);
    if (style.isolation === "isolate") reasons.push("isolation: isolate");
    if (/transform|translate|rotate|scale|opacity|filter|perspective|clip-path|mask|isolation/.test(style.willChange)) reasons.push(`will-change: ${style.willChange}`);
    if (/layout|paint|strict|content/.test(style.contain)) reasons.push(`contain: ${style.contain}`);
    if (style.containerType === "size" || style.containerType === "inline-size") reasons.push(`container-type: ${style.containerType}`);
    if (style.viewTransitionName && style.viewTransitionName !== "none") reasons.push(`view-transition-name: ${style.viewTransitionName}`);

    if (reasons.length > 0) results.push({ element: node, reasons: reasons.join(", ") });
  }

  console.table(results);
  return results;
}

The table lists every ancestor that seals your element's layering, nearest first, along with the property responsible. Most of the time, the culprit is near the top of the list.

Common Pitfalls

1. z-index on a Static Element

z-index does nothing on an element with position: static unless it's a flex or grid item. Add position: relative.

2. Raising the Child Instead of the Ancestor

If an ancestor creates a stacking context, the child's z-index can't help. Find the ancestor that represents it in the shared context, and change that — or move the child out.

3. An Animation That Leaves a Stacking Context Behind

Entrance animations are a frequent source of accidental stacking contexts:

@keyframes slide-in {
  from {
    transform: translateY(1rem);
  }

  to {
    transform: translateY(0);
  }
}

.panel {
  animation: slide-in 300ms ease-out both;
}

Because of both (or forwards), the panel keeps transform: translateY(0) after the animation ends. That value isn't none, so the panel remains a stacking context — and a containing block for fixed descendants — forever. End the animation on transform: none instead, or drop the fill mode if the final state matches the element's normal styles.

4. A position: fixed Modal Inside a Transformed Ancestor

A transform, filter, perspective, or will-change: transform on an ancestor doesn't just trap the modal's z-index — it also becomes the containing block for position: fixed, so the "full-screen" modal is sized and positioned relative to that ancestor. Render modals with <dialog> or a portal at the end of <body>.

5. Dropdowns Inside a Sticky Header

position: sticky always creates a stacking context, so a dropdown inside a sticky header can never paint above something that outranks the header itself. Give the header a high enough layer (such as var(--z-sticky)), or render the menu in the top layer with popover.

6. Treating Clipping as a Layering Problem

If the element is cut off at an ancestor's edge, it's being clipped by overflow, not covered. Changing z-index won't help. Remove the overflow constraint, use overflow: clip with overflow-clip-margin where appropriate, or move the element into the top layer.

7. The z-index Arms Race

Values like 9999, 99999, and 2147483647 (the largest value browsers can store) are a sign that the real problem hasn't been found. Replace them with a named scale, and fix the stacking context that made them necessary.

Frequently Asked Questions

Wrapping Up

z-index is simple once you see the model behind it:

  • The browser paints in a fixed order; z-index only moves positioned elements (and flex or grid items) into the bottom or top band of their group.
  • Those groups are stacking contexts. A child's z-index is only compared inside its own context, which is why 9999 can lose to 2.
  • Many properties create stacking contexts — including opacity, transform, filter, will-change, and position: sticky — often by accident.
  • isolation: isolate creates one on purpose, with no side effects. Use it to make components layering-safe.
  • For overlays, prefer the top layer, and keep everything else on a small, named z-index scale.

The next time an element refuses to come to the front, don't reach for another 9. Find its stacking context — the answer is almost always one level up.

If the playground's opacity: 0.99 and transform triggers felt familiar, the CSS Transitions Guide and CSS Keyframes Guide cover the animation side of those properties in depth.

This one is built around three new pieces: an exploded view of the seven painting layers, a version-number tree showing why 9999 can lose to 10, and a three-tab sandbox that runs real HTML and CSS rather than pictures of them — the trapped tooltip, the negative z-index box, and the blend-mode circles — each with a readout explaining what the browser just painted. Getting the playground's readout to agree with the browser in every combination — ties, z-index: 0 versus auto, an opacity of 0.99 that changes almost nothing on screen yet everything underneath — meant re-deriving the painting order more times than I'd like to admit. I hope the next time a 9999 refuses to budge, you go looking for its parent instead of adding another 9. Thanks for stacking up with me. 🥞