Why Your z-index Isn't Working: A Practical Guide to CSS Stacking Contexts

November 13, 2026

Every front-end developer has had this afternoon. You build a dropdown, it renders behind a card. You set z-index: 9999. Nothing changes. You try 99999. Still nothing. At that point you are not fighting a number — you are fighting a stacking context, and no amount of digits will win that fight.

css code on computer screen

This guide walks through how browsers actually decide paint order, which properties silently create stacking contexts, three bugs I've seen shipped more times than I can count, and a repeatable DevTools workflow for hunting them down.

First, how browsers stack things when z-index "works"

The biggest misconception about z-index is that it ranks every element on the page globally. It doesn't. It only ranks elements within the same stacking context. When no element creates a context, the browser paints in a fixed order:

  1. The background and borders of the stacking context's root
  2. Child stacking contexts with negative z-index
  3. In-flow, non-positioned block-level elements
  4. Non-positioned floats
  5. Inline content — your text
  6. Positioned elements with z-index auto or 0
  7. Child stacking contexts with positive z-index, lowest first

Two consequences fall out of this. First, when z-index values tie, DOM order wins — later markup paints on top. Second, the classic z-index: -1 trick only pushes an element behind its parent's background when the parent does not create a stacking context. If the parent creates one, the child paints above that background no matter what.

web developer debugging devtools

The properties that silently create stacking contexts

Nobody sets out to create a stacking context. They appear as side effects of completely reasonable CSS. Watch for these:

  • The root element — everything on your page lives in it by default
  • Any positioned element (relative, absolute, fixed, sticky) with a z-index other than auto. Sticky always creates one; fixed does in Chrome and Safari even without a z-index
  • Flex or grid items with a z-index set — no position required
  • opacity below 1 — and yes, 0.999 counts
  • transform, filter, backdrop-filter, perspective, clip-path, or mask with any value other than none
  • isolation: isolate — the explicit "create a context here" switch
  • mix-blend-mode with any value other than normal
  • will-change set to opacity, transform, or similar
  • contain with layout, paint, strict, or content

The transform and opacity entries are the ones that bite people. They're everywhere — hover effects, fade-in animations, GPU-acceleration "fixes" someone added three years ago. The full spec list lives in the MDN z-index docs, but the practical rule is simpler: if an ancestor animates or composites, it probably owns a stacking context.

Three bugs you have probably shipped

1. The modal trapped under the sticky header

Your header is position: sticky with z-index: 100, so it sits in the root stacking context at level 100. Your modal is position: fixed with z-index 1000 — but it's rendered inside a page wrapper that has a slide-in animation, so that wrapper carries transform: translateX(0). The wrapper now creates a stacking context at level auto, which is effectively 0. The modal's 1000 only competes inside the wrapper. Against the header, the whole wrapper ranks as 0 and loses. The modal could declare z-index a billion; the header still paints on top.

2. The dropdown swallowed by the next card

A card grid where each card has a hover lift: .card:hover { transform: translateY(-4px); }. A dropdown inside card 3 has z-index 50. When card 4 (later in the DOM, same stacking level) is hovered, it gains its own context and paints above everything inside card 3's context. The dropdown gets visually clipped at the card boundary even though there's no overflow: hidden in sight. This one feels like a browser bug every single time.

3. The tooltip that broke during a fade-in

A tooltip component fades in via an opacity animation on its wrapper. For the duration of that animation — and forever, if someone left opacity: 0.999 in the code to "fix a Safari rendering glitch" — the wrapper owns a stacking context. Any sibling that comes later in the DOM now paints over the tooltip, but only while it's fading. The bug appears and disappears depending on timing, which makes it maddening to reproduce.

Debugging stacking order in DevTools

Guessing is the slow path. Here's the workflow I use:

  1. Select the buried element in the Elements panel, then walk up the ancestor chain. In the Computed pane, check each ancestor for transform, opacity, filter, will-change, isolation, and position. Any non-default value is a suspect.
  2. Toggle suspects off. Uncheck transform on the wrapper in the Styles pane. If your element suddenly jumps to the front, you've found the context creator. This takes ten seconds and beats an hour of squinting.
  3. Use Edge's 3D View. Open More tools → 3D View and switch to the z-index mode. It renders your DOM as stacked planes so you can literally see which contexts sit above which. It's the fastest visual diagnosis available in any browser today.
  4. Check the Layers panel in Chrome (More tools → Layers). Elements with transforms and opacity often get their own composited layer. That's not proof of a stacking context, but it's a strong hint about where compositing is happening.
  5. Probe from the console. Walk up with parentElement in a loop, run getComputedStyle(node) on each ancestor, and log transform, opacity, filter, and zIndex. Thirty seconds of scripting finds the culprit in deeply nested component trees.

Fixes, from clean to pragmatic

  • Delete the trigger. If the transform on the wrapper was purely decorative, removing it is the cleanest fix. No transform, no context, no problem.
  • Move the effect off the ancestor. Animate a child element instead of the wrapper that contains your modal or dropdown. The stacking context then can't trap the overlay.
  • Render overlays at the body level. React portals and Vue Teleport exist precisely for this. A modal appended to the end of body joins the root stacking context and sidesteps every ancestor issue.
  • Use isolation: isolate deliberately. When a component's internal stacking (blends, fades) needs containing but shouldn't interact with the page, isolate it explicitly and document why.
  • Pragmatic escape hatch: give the trapped wrapper an explicit z-index that beats its rival — if the header is 100, set the wrapper to 101. It works, but leave a comment, because the next developer will be just as confused as you were.

A z-index policy that saves future pain

Most teams accumulate a graveyard of magic numbers — 99, 100, 999, 9999 — each added during a different emergency. Replace that with a named scale: header at 100, dropdowns at 1000, modals at 5000, toasts at 6000. The gaps give you room to insert new layers without renumbering, and a grep for the token list tells you instantly when two components might collide.

Add one review rule: any new transform, opacity, filter, or will-change on a component that contains an overlay deserves a second look. That single habit prevents the majority of stacking bugs before they exist.

Next time z-index ignores you, resist the urge to add another digit. Ask one question instead: which ancestor is creating a stacking context? Find it, and the fix is almost always one line.