Episode 1 — Fundamentals / 1.8 — CSS Layout Mastery

1.8.f — Stacking Context

In one sentence: A stacking context is an invisible boundary that determines how elements overlap — z-index only competes within the same stacking context, which is why "just add z-index: 9999" often does not work.

Navigation: ← 1.8.e — Positioning · 1.8.g — Container Queries →


1. What creates a stacking context

A new stacking context is formed by any element with:

TriggerExample
position: relative/absolute + z-index (not auto)position: relative; z-index: 1;
position: fixed or stickyAlways creates one
opacity less than 1opacity: 0.99; — yes, even 0.99
transform (any value except none)transform: translateZ(0);
filter (any value except none)filter: blur(0);
isolation: isolateExplicit context creation
Flex/grid child with z-index (not auto)z-index: 1 on a flex item
will-change with certain valueswill-change: transform;
contain: paint or contain: layoutCSS containment
mix-blend-mode (not normal)mix-blend-mode: multiply;

Key insight: The root element (<html>) is always a stacking context. Every other context is nested inside it.


2. z-index only works within context

z-index does not create a global layer system. It only determines stacking among siblings within the same stacking context.

Root stacking context (html)
├── Header (z-index: 100)          ← context A
│   ├── Logo (z-index: 5)         ← competes within A only
│   └── Dropdown (z-index: 999)   ← competes within A only
│
└── Modal (z-index: 50)            ← context B
    └── Close button (z-index: 1)  ← competes within B only

Even though the dropdown has z-index: 999, it cannot appear above the modal if the header's context (z-index: 100) is higher than the modal's context (z-index: 50). Wait — in this case the header IS higher (100 > 50), so the dropdown WOULD appear above. The bug happens in the reverse: if the header had z-index: 10 and the modal z-index: 50, no z-index on the dropdown could lift it above the modal.


3. Default paint order (no z-index)

Within a single stacking context, elements paint in this order (back to front):

  1. Background and borders of the stacking context root
  2. Negative z-index children (e.g. z-index: -1)
  3. Block-level elements in DOM order (normal flow)
  4. Floated elements
  5. Inline/inline-block elements in DOM order
  6. z-index: 0 and z-index: auto positioned elements
  7. Positive z-index children, in ascending order

Practical implication: A positioned element with z-index: auto still paints above non-positioned block elements in the same context.


4. Common z-index bugs

Bug 1: "z-index has no effect"

/* z-index is ignored on static elements */
.box {
  z-index: 100; /* does nothing — position is static */
}

Fix: Add position: relative (or the element must be a flex/grid child).

Bug 2: Modal appears behind sidebar

.sidebar {
  position: relative;
  z-index: 10; /* creates a stacking context */
}

.modal {
  position: fixed;
  z-index: 9999; /* but modal is INSIDE sidebar in the DOM */
}

If the modal is a DOM descendant of .sidebar, its z-index: 9999 only competes within the sidebar's stacking context. It cannot escape above siblings of .sidebar that have higher z-index.

Fix: Move the modal to a portal at the top level of the DOM (outside sidebar).

Bug 3: Unintentional stacking context from transform or opacity

.parent {
  transform: translateZ(0); /* GPU hint — creates stacking context */
}

.child {
  position: fixed; /* now fixed relative to .parent, not viewport! */
}

Fix: Be aware that transform, filter, opacity < 1, and will-change all create stacking contexts and alter containing blocks.

Bug 4: z-index arms race

.dropdown { z-index: 100; }
.toast    { z-index: 999; }
.modal    { z-index: 9999; }
.tooltip  { z-index: 99999; } /* where does it end? */

This is unmaintainable. Use a token-based system instead.


5. Token-based z-index system

Define z-index values as design tokens with clear semantic layers:

:root {
  --z-base:      0;
  --z-dropdown:  100;
  --z-sticky:    200;
  --z-overlay:   300;
  --z-modal:     400;
  --z-toast:     500;
  --z-tooltip:   600;
}

.dropdown { z-index: var(--z-dropdown); }
.sticky-header { z-index: var(--z-sticky); }
.modal-backdrop { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }
.tooltip { z-index: var(--z-tooltip); }

Benefits:

  • Every layer has a clear name and purpose.
  • Gaps between values (100, 200, …) leave room for additions.
  • No magic numbers scattered through the codebase.
  • Easy to audit by searching for --z-.

6. isolation: isolate

Sometimes you want to create a stacking context without side effects (no positioning, no opacity change). That is what isolation: isolate does:

.card {
  isolation: isolate; /* new stacking context, nothing else changes */
}

This is useful for component libraries — it ensures a component's internal z-index values cannot leak out and conflict with the rest of the page.


7. Debugging stacking with DevTools

Chrome DevTools

  1. Elements panel → Computed → z-index: See the computed z-index value.
  2. 3D View (Layers panel): Ctrl+Shift+P → "Show Layers" → visualize stacking contexts as 3D layers.
  3. Elements panel → highlight: Hover over elements to see their bounds and stacking.

Debugging workflow

  1. Identify which element is not stacking correctly.
  2. Walk up the DOM — find which ancestor creates a stacking context.
  3. Check if the z-index values compete in the same context or different ones.
  4. If different contexts, the fix is usually: restructure DOM, adjust ancestor z-index, or use a portal.

8. Key takeaways

  1. z-index is not global — it only competes within the same stacking context.
  2. Many properties create stacking contexts: opacity, transform, filter, position + z-index, isolation.
  3. Paint order matters even without z-index — positioned elements paint above non-positioned ones.
  4. Use a token-based z-index system to avoid arms races.
  5. Use isolation: isolate to contain z-index within components.
  6. Debug with the Layers panel and by walking the DOM to find context boundaries.

Explain-It Challenge

Explain without notes:

  1. Why can a z-index: 9999 element still appear behind a z-index: 1 element?
  2. Name three CSS properties (besides position + z-index) that create a stacking context.
  3. How does isolation: isolate help in a component-based architecture?

Navigation: ← 1.8.e — Positioning · 1.8.g — Container Queries →