CSSAdvanced

CSS Stacking Contexts: Why z-index: 9999 Doesn't Work

z-index isn't a global ranking. It only compares elements inside the same stacking context, and a dozen properties quietly create new ones. Learn the painting order, find the context that traps your element, and fix overlap bugs for good.

Layers of a web page shown in 3D: a modal with z-index 9999 trapped inside a layer that sits below the site header
On this page

It's one of the most common CSS bugs there is. The modal has z-index: 9999. The site header has z-index: 10. And the header is drawn on top of the modal. You try 99999, then !important; nothing changes.

The browser isn't broken. z-index is not a global ranking of every element on the page. It only orders elements within the same stacking context, and your modal is trapped inside a context that, as a whole, sits below the header. This article explains how the browser decides what's on top, what creates those contexts (often by accident), and how to find and fix the one causing your bug. It assumes you're comfortable with the cascade (The CSS Cascade and Specificity) and with positioning.

The bug#

HTML
<header class="site-header">…</header>
<main class="page">
  <div class="modal">…</div>
</main>
CSS
.site-header { position: sticky; top: 0; z-index: 10; }
.page        { position: relative; z-index: 1; }
.modal       { position: fixed; inset: 0; z-index: 9999; }

The modal is drawn underneath the header. To see why, we need two ideas: the painting order inside one stacking context, and how contexts nest.

Painting order inside a stacking context#

A stacking context is a group of elements that the browser paints together, as a unit. Inside one, it paints in this order, from bottom to top:

  1. The background and borders of the element that forms the context
  2. Positioned descendants with a negative z-index (most negative first)
  3. Normal block boxes, in document order
  4. Floats
  5. Inline content: text, images, inline-blocks
  6. Positioned descendants with z-index: auto or 0, in document order
  7. Positioned descendants with a positive z-index, lowest first

Two consequences surprise people. First, positioned elements paint above non-positioned ones even without any z-index: adding position: relative alone can bring an element to the front. Second, z-index only has an effect on positioned elements (relative, absolute, fixed, sticky) and on flex and grid items. On a plain static block it's ignored.

Contexts nest like version numbers#

Here's the crucial rule: a stacking context is painted as a single layer inside its parent context, at the position given by its own z-index. Everything inside it is drawn within that layer, so a child can never escape its parent's position, however high its own z-index is.

A good mental model is version numbers. In our example, the root context contains two layers, the header (10) and .page (1). The modal's 9999 only matters inside .page, so its effective position is something like 1.9999:

The DOM htmlheadermain.pagediv.modal root stacking context z-index: 10z-index: 1z-index: 9999 new stacking context What gets painted on top header10 main.page1 div.modal9999 only counts in here1.9999 html background At the root, 10 beats 1, so the header covers everything inside main.page
The root stacking context contains the header at z-index 10 and .page at z-index 1. The modal's z-index 9999 only applies inside .page, so its effective order is 1.9999, which is below 10

The browser first compares 10 with 1 at the root level, and the header wins. The 9999 is never compared with the header at all. That's the whole bug.

What creates a stacking context#

This is the list that matters, because most stacking contexts are created by accident, as a side effect of a property added for a different reason:

Property Creates a context when…
position: relative / absolute z-index is anything other than auto
position: fixed / sticky always
flex or grid item z-index is anything other than auto
opacity less than 1
transform, scale, rotate, translate anything other than none
filter, backdrop-filter anything other than none
clip-path, mask, perspective anything other than none
mix-blend-mode anything other than normal
isolation isolate
will-change names one of the properties above
contain layout, paint, strict or content
container-type size or inline-size

Plus the root element (<html>), which is always one.

So a fade-in animation (opacity: 0.99 during the transition), a transform: translateZ(0) "performance hack", or a will-change: transform on a wrapper can each trap every z-index inside it. Classic real-world causes:

  • An animated page wrapper with transform, which traps every modal and dropdown inside it.
  • A sticky header in one part of the page and a dropdown inside a different sticky section.
  • A card with opacity: 0.95 whose tooltip slides under the next card.

transform also breaks position: fixed

An ancestor with transform, filter, perspective or will-change: transform doesn't just create a stacking context: it also becomes the containing block for position: fixed descendants. Your "fixed" modal then positions itself relative to that ancestor instead of the viewport, and scrolls with it. If a fixed element is in the wrong place and in the wrong layer, look for a transform.

Finding the context that traps you#

DevTools don't show stacking contexts directly, but you can find them with a few lines of JavaScript. Select the misbehaving element in the Elements panel, then paste this into the console ($0 is the selected element):

JavaScript
function stackingContexts(el) {
  const found = [];
  for (let node = el.parentElement; node; node = node.parentElement) {
    const s = getComputedStyle(node);
    const parent = node.parentElement && getComputedStyle(node.parentElement);
    const reasons = [];
    const flexOrGridItem = /flex|grid/.test(parent?.display ?? "");
    if (s.zIndex !== "auto" && (s.position !== "static" || flexOrGridItem))
      reasons.push(`z-index: ${s.zIndex}`);
    if (s.position === "fixed" || s.position === "sticky") reasons.push(`position: ${s.position}`);
    if (s.opacity !== "1") reasons.push(`opacity: ${s.opacity}`);
    for (const prop of ["transform", "scale", "rotate", "translate", "filter", "backdropFilter", "perspective", "clipPath"])
      if (s[prop] && s[prop] !== "none") reasons.push(prop);
    if (s.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${s.mixBlendMode}`);
    if (s.isolation === "isolate") reasons.push("isolation: isolate");
    if (/transform|opacity|filter/.test(s.willChange)) reasons.push(`will-change: ${s.willChange}`);
    if (reasons.length) found.push({ element: node, reasons: reasons.join(", ") });
  }
  return found;
}

console.table(stackingContexts($0));

It walks up from the element and lists every ancestor that forms a stacking context, with the reason. It covers the common causes rather than every rule in the table. The first row is the context your z-index is trapped in; compare the z-index of the element you're fighting with against that ancestor's, not against your own.

Fixing it#

Once you know which context traps the element, pick the fix that matches the cause:

1. Remove the accidental context. If .page doesn't actually need z-index: 1 (or that transform, or will-change), delete it. This is the best fix when the property was added "just in case".

2. Move the element out. Overlays like modals, toasts and dropdown menus are best rendered as direct children of <body>, outside every component's context. Most UI frameworks have a tool for it: React's createPortal, Vue's <Teleport>, Svelte's portal libraries.

3. Use the top layer. Modern browsers have a top layer that sits above the entire document, outside all stacking contexts. Elements get there through the platform's own APIs, so no z-index is needed at all:

HTML
<dialog id="confirm">
  <p>Delete this file?</p>
  <form method="dialog"><button>Cancel</button> <button value="ok">Delete</button></form>
</dialog>

<script>
  document.getElementById("confirm").showModal();   // rendered in the top layer
</script>

<dialog> opened with showModal(), elements with the popover attribute, and fullscreen elements all render in the top layer, wherever they are in the DOM. showModal() also adds a ::backdrop, traps focus and closes on Esc, which are things a hand-made modal usually gets wrong.

4. Raise the whole context. If neither of the above works, change the z-index of the context root (.page), not the element inside it. Remember this moves everything inside .page along with it.

isolation: isolate, the useful kind of context#

Sometimes you want a stacking context. A common example is a decorative layer behind a card's content:

CSS
.card { position: relative; background: white; }
.card::before {
  content: "";
  position: absolute;
  inset: -4px;
  z-index: -1;                 /* behind the card's content... */
  background: linear-gradient(135deg, violet, orange);
  border-radius: inherit;
}

Without a stacking context on .card, the ::before with z-index: -1 is painted in whatever context the card lives in, at step 2 of the painting order: below the card's own white background. The glow is hidden behind the card it was meant to frame, and could even slide behind the backgrounds of the card's ancestors.

Give the card its own context and the problem disappears:

CSS
.card { position: relative; background: white; isolation: isolate; }

Now the ::before is painted inside the card's context: above the card's background, below its content. isolation: isolate exists for exactly this. It creates a context and does nothing else: no positioning, no z-index value to manage, no side effects.

A z-index scale for the rest

For elements that really do share a context, avoid an arms race of ever-bigger numbers. Define a small scale once, for example --z-dropdown: 10; --z-sticky: 20; --z-overlay: 30; --z-toast: 40;, and only use those values. If something needs to go between two levels, that's a design decision, not a number to make up.

Recap#

  • z-index only orders elements inside the same stacking context; contexts nest, so think in version numbers like 1.9999 < 10.
  • Within a context the painting order is: background, negative z-index, blocks, floats, inline content, z-index: auto/0, positive z-index.
  • z-index only works on positioned elements and flex/grid items.
  • Many properties create contexts as a side effect: opacity < 1, transform, filter, will-change, fixed/sticky positioning and more.
  • Fix overlap bugs by removing accidental contexts, moving overlays to <body>, or using the top layer (<dialog>, popover). Use isolation: isolate when you want a context on purpose.