Skip to content

CSS z-index Not Working: Stacking Context Causes & Fixes

z-index not working even with a huge value? It's almost never a z-index bug — an ancestor is trapping your element in its own stacking context. Here's why, and the fix.

css z-index stacking-context css-layout css-position isolation browser-support frontend
Bharath G
Reading Progress

On This Page

1. The Symptom

A dropdown menu, modal, tooltip, or sticky header renders behind another element even though you gave it a much higher z-index. Classic version: a nav dropdown with z-index: 9999 still appears underneath a hero image or a card two levels up in the DOM that only has z-index: 1 — or no z-index at all.

There's no console error. Nothing is crossed out in DevTools' Styles pane — z-index: 9999 shows as the winning, applied declaration. That's what makes this one so disorienting: the property computed exactly as written, and it still doesn't do what you expect.

The DevTools evidence that actually explains it: open the Elements panel, select the buried element, and check the Layers view (Chrome/Edge: More tools → Layers, or the small stacking icon next to elements in the Elements tree; Firefox: the badge next to an element that creates a stacking context). You'll see the offending ancestor listed as its own stacking context, with your high-z-index element nested inside it — competing only with its own siblings, not with the element it's visually supposed to beat.

This is engine-independent. Stacking contexts are CSS2-era behavior (z-index shipped in CSS 2.1, 1998), and every evergreen browser — Chrome/Edge (Blink), Firefox (Gecko), Safari (WebKit) — implements the stacking algorithm identically. If your layout looks different across browsers here, the bug is elsewhere (usually a browser-specific default that also happens to trigger a stacking context, like Safari giving buttons different default styles).

2. How to Reproduce It

Minimal repro — a raised card sits inside a transform-ed ancestor, and a dropdown inside that card can never climb above a sibling <div> outside it, no matter what z-index you give it:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
  body { font-family: system-ui, sans-serif; margin: 40px; }

  .card {
    position: relative;
    transform: translateZ(0); /* innocuous-looking perf hint */
    width: 260px;
    padding: 16px;
    background: #eef2ff;
    border-radius: 8px;
  }

  .dropdown {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 9999; /* as high as it gets */
    width: 220px;
    padding: 12px;
    background: white;
    box-shadow: 0 8px 24px rgba(0,0,0,.2);
    border-radius: 8px;
  }

  .banner {
    position: relative;
    z-index: 1; /* comically lower than 9999 */
    margin-top: -20px;
    height: 120px;
    background: #fca5a5;
  }
</style>
</head>
<body>
  <div class="card">
    Account menu
    <div class="dropdown">This should float above everything, but doesn't.</div>
  </div>
  <div class="banner"></div>
</body>
</html>

Open it in any browser: .dropdown (z-index: 9999) renders under .banner (z-index: 1). The transform: translateZ(0) on .card — often added as a GPU-acceleration hint, or emitted automatically by an animation library — creates a new stacking context. .dropdown's z-index: 9999 is now only meaningful inside .card's stacking context; .card itself, as a single atomic unit, stacks against .banner using default order (both are position: relative with .banner's explicit z-index: 1 beating .card's implicit auto, i.e. 0).

No build step is needed to reproduce this — it's pure CSS. Where it gets harder to spot in real apps: the stacking-context-creating property is rarely this obvious. It's commonly hiding in a component library's transform-based enter/exit animation, a will-change: transform left on permanently for perceived performance, a filter: drop-shadow(...) used for a card shadow, or a CSS Grid/Flex item that picked up a z-index from a shared utility class.

3. Browser & Baseline Support Matrix

The stacking context algorithm itself is evergreen and engine-independent — it's been identical across Blink, Gecko, and WebKit for as long as any of them have shipped z-index. There's nothing to feature-detect and no @supports query that applies; this section instead covers the newer properties that also create stacking contexts, since those are the ones that surprise people who learned the "old" rules (positioned + z-index, opacity < 1) and got bitten by a newer trigger.

TriggerChrome / EdgeFirefoxSafari (incl. iOS)Baseline
position (not static) + z-indexautoall versionsall versionsall versionsWidely available (CSS2.1)
opacity < 1all versionsall versionsall versionsWidely available
transform/filter/perspectivenoneall versionsall versionsall versionsWidely available
will-change naming a stacking-context property36+36+9.1+Widely available
isolation: isolate41+36+8+Widely available (since ~2020 in practice, spec since 2015)
contain: layout / paint / strict / content52+69+15.4+Widely available
container-type: size / inline-size (containers also become stacking contexts)105+110+16+Widely available
Top layer (popover, <dialog> when shown modally, fullscreen)114+125+17+Newly available — verify current status on webstatus.dev before relying on it cross-browser

Verify exact version numbers on webstatus.dev or caniuse.com before publishing anything that depends on the newer rows — container-type and the top layer are recent enough that a project supporting older enterprise browsers (old Safari on managed iPads, embedded WebViews) can still hit gaps.

Progressive enhancement angle: there isn't a feature query that detects "does this ancestor create a stacking context," because it's a consequence of other properties, not a property itself. The practical @supports use here is checking for isolation support before relying on it as your isolation strategy: @supports (isolation: isolate) { .layer { isolation: isolate; } } — though at this point isolation is safe to use unconditionally.

4. Why It Happens — Surface Level

z-index doesn't compare elements globally. It only orders elements within the same stacking context — the nearest ancestor (or the element itself) that the browser has decided is a self-contained layering unit. Once an ancestor becomes a stacking context, everything inside it is painted as one atomic slab relative to that ancestor's siblings. A z-index: 9999 five levels deep can't "reach out" past its own stacking-context boundary to out-rank something outside it; it can only win against its siblings inside that boundary.

So the fix is almost never "raise the z-index higher." It's "find which ancestor accidentally created a stacking context, and either remove the trigger, move the DOM structure, or promote the outer element's stacking context instead."

5. Why It Happens — Under the Hood

The rendering pipeline builds a stacking order — a paint order for overlapping boxes — that's computed recursively per stacking context, not globally per element. Each stacking context produces an ordered list, from back to front:

  1. The stacking context's own background and border.
  2. Negative z-index descendants (most negative first) — these are the only children that can paint behind the stacking context's own background, but never behind the parent's own parent; a negative z-index escapes its immediate sibling stack, not the whole page.
  3. In-flow, non-positioned, non-floated block-level descendants (source order).
  4. Floated descendants.
  5. In-flow inline-level descendants.
  6. z-index: 0 / positioned descendants without an explicit stacking context of their own, and descendants that themselves establish stacking contexts — all interleaved by z-index value.
  7. Positive z-index descendants (least positive first) — child stacking contexts are inserted here as whole atomic units.

That word "atomic" is the whole story: per the CSS2.1 stacking-context algorithm (still the operative spec text, refined by later modules), "each stacking context is considered atomically." Once your .card establishes a stacking context, the browser paints all of .card's contents as a single layer at whatever position .card occupies in its parent's stacking order. It doesn't matter that .dropdown inside .card has z-index: 9999 — that value only settles disputes among .card's own descendants. From the outside, .card is one sealed unit competing against .banner using .card's own z-index (or auto, which sorts as if it were 0 but doesn't create its own context unless something else forces one).

This is also why z-index is often described as "scoped, not global": there is no single page-wide z-axis. There's a tree of stacking contexts, each with its own local ordering, nested inside each other exactly like the DOM — except a stacking context's boundary doesn't have to align with anything visually obvious, which is what makes debugging this by eye so unreliable.

The reason the trigger list keeps growing (from just "positioned + z-index" and "opacity < 1" in early CSS, to transform, filter, will-change, contain, isolation, container-type, and the top layer today) is that each of these properties needs the browser to allocate an independent compositor layer or rendering isolation boundary for other reasons — GPU compositing for transform/filter, style/layout containment for contain/container-type, blend-mode isolation for isolation, and OS-level always-on-top rendering for the top layer. Stacking-context creation is the CSS-visible side effect of the browser deciding "this subtree needs to be paint-isolated from its surroundings" for a mechanical reason that's often unrelated to your actual z-index bug — which is exactly why transform: translateZ(0) (a old GPU-layer-promotion hack) or a leftover will-change: transform is such a common, invisible culprit.

isolation: isolate is the one entry in that list that creates a stacking context and does nothing else — no compositing changes, no containment, no visual effect at all beyond establishing the boundary. That's exactly what makes it the correct tool when you want a stacking context on purpose, instead of getting one as an unwanted side effect of a transform you needed for something else.

6. The Fix

Diagnose first. In Chrome/Edge DevTools, open the Layers panel (⋮ → More tools → Layers) or hover the small stacking-context icon next to an element in the Elements tree; Firefox's Inspector marks stacking-context-creating elements with a badge. Walk up from the buried element until you find the ancestor that shouldn't be isolating.

Option A — remove the accidental trigger (best when the property isn't load-bearing):

 .card {
   position: relative;
-  transform: translateZ(0);
 }

If that transform: translateZ(0) was a leftover GPU hint from years ago and nothing currently animates .card, deleting it is the correct fix, not a workaround.

Option B — keep the trigger, promote the whole ancestor instead (when the transform/filter is load-bearing, e.g. it's actively animating):

 .card {
   position: relative;
   transform: translateZ(0);
-  z-index: auto;
+  z-index: 10; /* now competes with .banner as a whole unit */
 }
 .banner {
   position: relative;
-  z-index: 1;
+  z-index: 1; /* .card (10) now correctly wins */
 }

This works because once you accept that .card is a sealed stacking context, you stop trying to out-rank .banner from inside it and instead give .card itself a competitive z-index at the level where the fight is actually happening.

Option C — the correct long-term tool for overlays: escape the DOM ancestor entirely. Don't fight the stacking context — render the dropdown/tooltip/modal at the top level instead, via position: fixed combined with JS positioning (or, natively, the popover attribute — see Section 7).

What doesn't actually fix this, and why:

  • Bumping z-index further (99999, 2147483647) — does nothing once you're trapped inside a sealed stacking context; you're not competing with the element you think you are.
  • Sprinkling !important on z-indexz-index isn't being overridden by a competing rule here (that's a specificity problem, a different bug); !important doesn't cross stacking-context boundaries, so it's a no-op for this failure mode.
  • position: fixed without addressing the ancestorposition: fixed still respects a stacking context, and worse, a transform/filter/perspective/contain: layout|paint/will-change: transform ancestor also becomes the containing block for fixed descendants, so the element may visually detach from where you expect it to render at all (a related, frequently-confused bug — see the containing-block article on this site).

7. Best Practices & The Better Design

Treat z-index as a small, deliberate scale, not an escalating arms race. A documented scale removes the temptation to guess-and-check with 999/9999/99999:

:root {
  --z-dropdown: 10;
  --z-sticky-header: 20;
  --z-overlay-backdrop: 30;
  --z-modal: 40;
  --z-toast: 50;
}

Use isolation: isolate deliberately on components that manage their own internal layering (a card with a hover overlay, a carousel with layered slides), so their internal z-index values can never leak out and collide with the rest of the page — and so you can tell at a glance, in the source, exactly which elements are meant to be stacking contexts instead of discovering it by accident in DevTools:

.carousel {
  isolation: isolate; /* internal z-index values are now sealed in here */
}

For anything that needs to visually escape its DOM ancestor's clipping and stacking — dropdowns, tooltips, modals, toasts — prefer the top layer over manual z-index gymnastics. The popover attribute (and <dialog> shown via .showModal()) renders the element in a browser-managed layer that sits above the entire document, immune to ancestor overflow: hidden, transform, and stacking-context traps, with no z-index required at all:

<button popovertarget="menu">Account</button>
<div id="menu" popover>
  This is always on top — no z-index, no escaping ancestors.
</div>

This is the same mechanism this site's position: sticky article flagged as the modern alternative to overflow: hidden-clipped dropdowns — the two failure modes (stacking-context traps, and overflow clipping) both get solved by the same top-layer primitive today.

8. How to Prevent It Long-Term

Add stylelint rules that catch the drift before it ships: declaration-property-value-no-unknown catches typos in z-index/isolation values, and a simple custom rule or code-review checklist item — "no bare integer z-index outside the tokens file" — stops the escalating-magic-number pattern before it starts. Keep your z-index scale as CSS custom properties in one file, checked into the repo, so a new dropdown always reads var(--z-dropdown) instead of a freshly invented number.

Add visual regression tests (Playwright screenshots across Chromium, Firefox, and WebKit projects, or Percy/Chromatic) for any component that layers over page content — a stacking-context regression is visually obvious in a screenshot diff and easy to miss in a code review that only reads the CSS.

When reviewing a PR that adds transform, filter, will-change, or contain to an existing component, explicitly check whether that component or its children rely on z-index to stack against anything outside it — that's the exact moment this bug gets introduced, and it's cheap to catch at review time versus in production.

Finally, prefer the top layer (popover, <dialog>) for new overlay components going forward. Every dropdown, tooltip, and modal built that way is one you'll never debug a stacking-context escape for again.

9. Key Takeaways

  • z-index only orders elements within the same stacking context — a higher value can't out-rank something outside the nearest ancestor that establishes one, no matter how large the number.
  • Stacking contexts are created by far more than "positioned + z-index": opacity < 1, transform/filter/perspectivenone, will-change, contain: layout|paint, isolation: isolate, container-type: size|inline-size, and top-layer elements all trigger one — and most of these are added for reasons that have nothing to do with layering.
  • Diagnose with DevTools' Layers panel or the stacking-context badge in the Elements tree — don't guess by raising numbers.
  • Use isolation: isolate when you want a stacking context on purpose; use the popover attribute or <dialog> to skip the whole problem for overlays that need to render above everything.
  • !important and bigger numbers don't fix this class of bug — the value is computing correctly; the boundary it's trapped in is the actual problem.

Related: this article assumes the ancestor's stacking-context boundary is the whole problem. If an overlay is also visually detaching to the wrong position (not just painting behind something), check whether that same transform/filter/contain ancestor has also become the containing block for a position: fixed descendant — a related but distinct failure covered in this site's containing-block article.

cssz-indexstacking-contextcss-layoutcss-positionisolationbrowser-supportfrontend

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments