Skip to content
C Codeloom
HTML & CSS

CSS z-index and Stacking Contexts

Understand z-index by understanding stacking contexts: what creates them, how they nest, and why your z-index 9999 sometimes does nothing.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Why z-index is local, not global
  • What creates a new stacking context
  • How to read a stacking context tree
  • How to fix the classic "9999 does nothing" bug
  • Best practices for predictable layering

Prerequisites

  • Comfortable with HTML and CSS positioning

Almost every developer has typed z-index: 9999 in frustration when a modal refused to appear on top of something. The number rarely solves the problem because z-index is not a global score. It is a local rank inside a tree of stacking contexts. Once you see the tree, the bug becomes obvious.

What stacking is and why it matters

When the browser paints the page, it has to decide the order in which elements are drawn. Elements painted later appear on top. The painting order is determined by a set of rules called stacking, and z-index is one input among many.

Without understanding stacking, you end up sprinkling z-index values across the codebase, escalating them every time something breaks, and creating modals that hide behind dropdowns for reasons no one can explain.

Mental model

A stacking context is a self-contained painting bubble. Inside a bubble, children are sorted by their z-index relative to each other. The bubble itself has a z-index in its parent bubble. A child can never appear above another bubble that sits higher in the parent, no matter how large the child’s z-index is.


<root context z:auto>
  +-- header  context z:10
  |     +-- logo z:1
  |     +-- nav  z:2
  +-- main    context z:1
        +-- modal z:9999   <-- still below header
Stacking contexts form a tree; z-index is local to each context

The modal inside main has z-index 9999, but main has z-index 1 and header has z-index 10. The modal cannot beat the header without leaving main’s bubble.

Several properties create a new stacking context: position non-static with a z-index value, opacity less than one, transform other than none, filter, will-change on certain properties, isolation: isolate, and a few more. Each one of these is a quiet source of bugs because the visual change you intended also rearranges the painting tree.

Hands-on example

Let us reproduce the classic bug. We have a fixed header and a card containing a modal.

<header class="header">Header</header>
<main class="main">
  <div class="card">
    <div class="modal">Modal</div>
  </div>
</main>
.header { position: fixed; top: 0; z-index: 10; }
.main   { position: relative; }
.card   { transform: translateZ(0); }  /* sneaky */
.modal  { position: fixed; z-index: 9999; }

The transform on .card creates a stacking context. The modal, even though it is position: fixed and z-index 9999, is trapped inside the card. Its containing block becomes the card and its painting order is judged inside the card’s bubble. The card itself has no z-index, so the modal cannot beat the header.

The fix is one of: remove the transform, move the modal out of the card using a portal, or raise the card’s stacking context above the header.


body
 |-- header (fixed, z:10)
 |-- main (relative, z:auto)
      +-- card (transform -> new context, z:auto)
            +-- modal (fixed, z:9999)
                effective stack: 9999 inside card,
                but card itself is below header
A transform created a new stacking context that traps the modal

This is also why fixed elements escape to weird places when an ancestor uses transform: they form not only a new stacking context but also a new containing block.

Common pitfalls

The first pitfall is assuming z-index works on static elements. It does not. The element must be positioned, or be a flex or grid child, for z-index to apply.

The second is reaching for opacity for animation purposes and accidentally creating a stacking context. A header that fades in might lift its entire subtree into a new bubble, and suddenly a tooltip cannot appear above a sibling section.

The third is the inflation arms race. Once z-index: 100 appears, the next bug becomes z-index: 1000, then 9999, then 99999. Numbers get bigger but the structural problem remains.

The fourth is portals not being used when they should be. If you build a modal as a deeply nested child, no amount of z-index will let it cleanly sit above the rest of the app. Portaling to the body solves the structural problem.

Best practices

Define a small scale of z-index values in your design tokens, for example dropdown 100, sticky 200, overlay 300, modal 400, toast 500. Reference the tokens instead of raw numbers. The scale becomes self-documenting and prevents inflation.

Use isolation: isolate on components that should never bleed their stacking context to ancestors. It creates a new stacking context without any visual side effects, which is exactly what you want for a self-contained widget.

When debugging, open dev tools and look for any ancestor with transform, opacity, filter, or will-change. Those are the usual suspects.

Reach for portals or render-roots for true overlays. The browser has no built-in mechanism that lets a nested element jump out of its parent’s stacking context. Moving the DOM node is the structural fix.

Wrap-up

z-index is not a global priority number. It is a local rank inside a stacking context, and contexts form a tree. The next time a layered element refuses to appear on top, do not raise the number. Walk up the DOM and find the ancestor that created an unexpected stacking context. The fix usually lives there. Once you carry this picture in your head, layering bugs become a five minute investigation instead of an afternoon of frustration.