Understanding Z-Index and Stacking Contexts

· 9 min read · CSS

Deep dive into CSS stacking contexts and why z-index values sometimes seem to have no effect.

Understanding Z-Index and Stacking Contexts

You set z-index: 9999 on an element and it still appears behind something with z-index: 1. This happens because z-index does not work globally—it works within stacking contexts.

Understanding stacking contexts is essential for debugging z-index issues and building predictable layered interfaces.

Problem

Developers encounter these scenarios:

.modal {
  z-index: 9999; /* Should be on top */
}

.dropdown {
  z-index: 1; /* Lower, should be behind */
}

Yet the dropdown appears above the modal. Increasing z-index values does not help. The problem is not the numbers—it is the stacking context hierarchy.

Why It Happens

What Creates a Stacking Context

A stacking context is like a layer group. Elements within a stacking context are layered relative to each other, but the entire group is treated as a single unit when compared to sibling contexts.

These CSS properties create new stacking contexts:

/* Each of these creates a stacking context */
.context-creator {
  position: relative; z-index: 1; /* position + z-index */
}

.another-creator {
  opacity: 0.99; /* Any opacity < 1 */
}

.transform-creator {
  transform: translateZ(0); /* Any transform */
}

.filter-creator {
  filter: blur(0); /* Any filter */
}

.isolation-creator {
  isolation: isolate; /* Explicit isolation */
}

NOTE: position: relative alone does NOT create a stacking context. It requires a z-index value (including z-index: 0) to create one.

The Hierarchy Problem

Consider this structure:

<div class="sidebar" style="position: relative; z-index: 1;">
  <div class="dropdown" style="z-index: 9999;">
    <!-- z-index: 9999 is huge! -->
  </div>
</div>

<div class="main" style="position: relative; z-index: 2;">
  <div class="modal" style="z-index: 1;">
    <!-- z-index: 1 is tiny -->
  </div>
</div>

The dropdown has z-index: 9999 but appears behind the modal with z-index: 1. Why?

Because the sidebar context (z-index: 1) is compared to the main context (z-index: 2) first. Main wins. Everything inside main appears above everything inside sidebar, regardless of individual z-index values.

Solution

Structure your stacking contexts intentionally and avoid creating unnecessary contexts.

Implementation

Define a z-index scale for your project:

:root {
  --z-base: 0;
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-fixed: 300;
  --z-modal-backdrop: 400;
  --z-modal: 500;
  --z-popover: 600;
  --z-tooltip: 700;
}

Use isolation: isolate to create explicit stacking contexts:

.card {
  isolation: isolate; /* Creates context without side effects */
}

Example

Fixing the modal/dropdown issue:

<!-- Before: Broken hierarchy -->
<div class="app">
  <div class="sidebar" style="position: relative; z-index: 1;">
    <div class="dropdown" style="z-index: 9999;">...</div>
  </div>
  <div class="main" style="position: relative; z-index: 2;">
    <div class="modal" style="z-index: 1;">...</div>
  </div>
</div>

<!-- After: Flat context at app level -->
<div class="app">
  <div class="sidebar">
    <!-- No z-index on sidebar -->
  </div>
  <div class="main">
    <!-- No z-index on main -->
  </div>
  
  <!-- Portaled elements at root level -->
  <div class="dropdown" style="z-index: 100;">...</div>
  <div class="modal" style="z-index: 500;">...</div>
</div>

By rendering modals and dropdowns at the root level (using React portals or similar), they share the same stacking context and z-index works predictably.

// React portal example
import { createPortal } from 'react-dom';

function Modal({ children }: { children: React.ReactNode }) {
  return createPortal(
    <div className="modal-backdrop">
      <div className="modal">
        {children}
      </div>
    </div>,
    document.body // Render at body level
  );
}

Common Mistakes

Adding z-index without position

Z-index has no effect on static elements:

/* Does nothing */
.element {
  z-index: 100;
}

/* Works */
.element {
  position: relative;
  z-index: 100;
}

Escalating z-index values

When z-index stops working, developers often try higher values:

.modal {
  z-index: 9999;
}

/* Later... */
.newer-modal {
  z-index: 99999;
}

/* Even later... */
.newest-modal {
  z-index: 999999999;
}

WARNING: This is a symptom, not a solution. The problem is stacking context hierarchy, not insufficient z-index values.

Opacity creating unwanted contexts

Adding even subtle opacity creates a stacking context:

.card:hover {
  opacity: 0.95; /* Creates stacking context */
}

Now everything inside .card is confined to that context. Use filter or transform carefully for the same reason.

Debug technique

Add this to visualize stacking contexts:

* {
  outline: 1px solid rgba(255, 0, 0, 0.2);
}

[style*="z-index"],
[style*="transform"],
[style*="opacity"],
[style*="filter"] {
  outline: 2px solid blue !important;
}

Conclusion

Z-index works within stacking contexts, not globally. Debug by identifying which ancestor creates the relevant context. Use React portals to render overlays at the document root where z-index values can be compared directly. Define a z-index scale and stick to it.