Code of the Day
IntermediateLayout Systems

z-index and Stacking Contexts

z-index controls layering — but it only works within a stacking context, which explains why z-index 9999 sometimes has no effect at all.

Web FoundationsIntermediate6 min read
Recommended first
By the end of this lesson you will be able to:
  • Explain what z-index controls and which elements it applies to
  • Define what a stacking context is and why it creates an isolated layering group
  • List the CSS properties that create a new stacking context
  • Diagnose the "z-index ignored" bug by inspecting ancestor stacking contexts

Every developer has written z-index: 9999 and watched it do nothing. The element stays buried. You add more nines. Still nothing. The number is not the problem. Understanding is.

What z-index does (and does not do)

z-index sets the stack level of an element within its stacking context. Higher values draw on top. It only applies to:

  • Positioned elements — anything with position other than static.
  • Flex items and grid items — even without position: relative.

On a plain position: static element, z-index is completely ignored, regardless of the value. This is the first thing to check when z-index appears to have no effect.

/* z-index ignored: static element */
.box { z-index: 10; }

/* z-index works: positioned element */
.box { position: relative; z-index: 10; }

What a stacking context is

A stacking context is a self-contained layering group. Elements inside it are painted together as a unit, and their z-index values only compete with siblings inside the same stacking context — never with elements in a different context.

Think of it as a separate layer in a design tool. You can arrange items within a layer freely, but the entire layer sits above or below other layers as a unit. An item deep inside a low-priority layer cannot reach up through it to appear above an item in a high-priority layer, no matter how large its z-index is.

Stacking context: body (root)
├── .modal-backdrop (z-index: 50)  ← paints on top
│   └── .modal (z-index: 1)        ← 1 within its context, still on top of everything below
└── .header (z-index: 100)         ← 100 within root context
    └── .dropdown (z-index: 9999)  ← 9999 within header's context only

In this tree, .dropdown loses to .modal-backdrop even though 9999 is greater than 50. The comparison never happens: .dropdown is inside .header's stacking context, and .header's context (z-index 100) loses to .modal-backdrop (z-index 50) — wait, 100 is greater than 50, so .header wins… but .modal-backdrop is trying to sit above the header. The example illustrates that the comparison happens at the stacking context level first, before any child z-indexes are evaluated.

The root stacking context is established by the <html> element. Every z-index comparison ultimately resolves in the context of a common ancestor. Finding that ancestor is the key to debugging z-index problems.

What creates a new stacking context

The list is longer than most developers realize:

PropertyCondition
positionwith z-index other than auto
opacityless than 1
transformany value other than none
filterany value other than none
will-changeany value that would trigger a stacking context
isolationisolate
mix-blend-modeany value other than normal

The surprising ones are opacity, transform, and filter. Adding transform: translateZ(0) to an element as a "performance hack" silently creates a new stacking context, which can trap child elements that were previously escaping to a higher context.

The debugging approach

When z-index is not working:

  1. Check that the element has position set (anything but static) or is a flex/grid item. No position, no z-index.

  2. Find every ancestor from the element up to the root. For each ancestor, check whether it has any of the stacking-context-creating properties listed above.

  3. If an ancestor has a stacking context, your element's z-index only applies within that ancestor's context. The ancestor's own z-index (relative to its context) determines how the whole group stacks against the rest of the page.

  4. The fix is usually one of:

    • Move the element higher in the DOM tree, outside the limiting ancestor.
    • Raise the ancestor's own z-index so its context sits above what is covering it.
    • Remove the property on the ancestor that is creating the unintended stacking context (commonly an unnecessary transform: translateZ(0)).
/* Before: modal inside a transformed container — z-index is trapped */
.page-section {
  transform: translateX(0);  /* creates a stacking context */
}
.modal {
  position: fixed;
  z-index: 1000;  /* only matters within .page-section's context */
}

/* Fix: remove the transform or restructure so the modal is outside the section */
.page-section {
  /* transform removed; use a different approach to the original problem */
}

z-index: 9999 is almost always a symptom, not a solution. When you reach for a large number, it means something in the stacking context tree is not structured the way you think. Investigate the ancestor chain instead of escalating the number.

The isolation shortcut

If you want to contain a component's z-index values without introducing an unwanted transform or opacity, isolation: isolate creates a stacking context with no other visual side effects:

.modal-container {
  isolation: isolate;  /* creates a stacking context cleanly */
}

This is the intended way to scope z-index to a component boundary in design systems. It says: "everything inside this element competes with each other for layering; nothing inside escapes to compete with elements outside."

Where to go next

You have now covered every major CSS layout system: normal flow, Flexbox, Grid, positioning, and stacking. The final stop in this module is hands-on practice: Lab — Layout Challenge — build a two-column blog layout and a responsive card grid from scratch, applying everything from this module.

Finished reading? Mark it complete to track your progress.

On this page