CSSHard

How do z-index and stacking contexts work?

01

The Short Answer

z-index controls the stacking order of positioned elements (anything with position other than static). But it doesn't work in a single global layer — it operates within stacking contexts. A stacking context is like a self-contained layer group: elements inside it are stacked relative to each other, and the entire group is stacked as a unit in the parent context. This is why setting z-index: 9999 sometimes doesn't work — the element might be trapped inside a stacking context that's behind another one.

02

How z-index Works (The Basics)

By default, elements stack in document order — later elements appear on top of earlier ones. z-index lets you override this order, but only for positioned elements. An element with position: static (the default) ignores z-index entirely. Higher z-index values appear in front of lower ones within the same stacking context.

basic-z-index.csscss
/* z-index only works on positioned elements */
.behind {
  position: relative;
  z-index: 1;
  background: red;
}

.in-front {
  position: relative;
  z-index: 2; /* Higher = in front */
  background: blue;
  margin-top: -20px; /* Overlaps .behind */
}

/* ❌ z-index is IGNORED on static elements */
.broken {
  position: static; /* default */
  z-index: 9999; /* Does nothing! */
}

/* Default stacking order (no z-index, all positioned):
   1. Root element background/borders
   2. Non-positioned elements (in document order)
   3. Positioned elements (in document order)
   Within positioned elements, z-index controls order */

The first rule to remember: z-index requires position: relative, absolute, fixed, or sticky. Without positioning, z-index is completely ignored by the browser.

03

What Creates a Stacking Context

A stacking context is created whenever certain CSS properties are applied to an element. Once created, all children of that element are stacked within it — their z-index values only compete with siblings in the same context, not with elements outside it. The root <html> element always creates the initial stacking context.

  • Root element (<html>) — always creates the base stacking context
  • position: absolute/relative with z-index other than auto
  • position: fixed or sticky (always creates a stacking context)
  • opacity less than 1
  • transform (any value other than none)
  • filter (any value other than none)
  • will-change: transform, opacity, or filter
  • isolation: isolate (explicitly creates one)
  • mix-blend-mode other than normal
  • contain: layout or paint
  • Flex/grid children with z-index other than auto

This is why adding transform: translateZ(0) or opacity: 0.99 as a 'performance hack' can break z-index stacking — these properties create new stacking contexts, changing how child elements layer relative to the rest of the page.

04

The Stacking Context Trap

The most common z-index frustration happens when an element is inside a stacking context with a lower z-index than a sibling context. No matter how high you set the child's z-index, it can never escape its parent's stacking context. The child's z-index only determines its position within the parent — the parent's z-index determines where the entire group sits relative to other contexts.

stacking-context-trap.htmlhtml
<!-- The classic z-index trap -->
<style>
  .parent-a {
    position: relative;
    z-index: 1; /* Creates stacking context */
  }
  .parent-b {
    position: relative;
    z-index: 2; /* Creates stacking context — ABOVE parent-a */
  }
  .child-of-a {
    position: absolute;
    z-index: 99999; /* Doesn't matter! Trapped inside parent-a's context */
  }
  .child-of-b {
    position: absolute;
    z-index: 1; /* Still appears ABOVE child-of-a */
  }
</style>

<div class="parent-a">
  <div class="child-of-a">I'm z-index 99999 but BEHIND child-of-b</div>
</div>
<div class="parent-b">
  <div class="child-of-b">I'm z-index 1 but IN FRONT of child-of-a</div>
</div>

<!--
  Why? parent-a (z:1) is behind parent-b (z:2).
  child-of-a's z:99999 only matters WITHIN parent-a.
  The entire parent-a group (including all children) is behind parent-b.
-->

Think of stacking contexts like folders on a desk. You can reorder papers within a folder (child z-index), but the folder's position in the stack (parent z-index) determines whether those papers are above or below papers in other folders. No amount of reordering within a bottom folder puts a paper above the top folder.

05

Debugging z-index Issues

When z-index isn't working as expected, follow this debugging checklist. Most z-index bugs come from one of three causes: the element isn't positioned, it's trapped in a lower stacking context, or a parent has a property that accidentally creates a stacking context (like transform or opacity).

  • Check if the element has position other than static (z-index requires it)
  • Inspect parent elements — find which one creates the stacking context
  • Look for transform, opacity < 1, filter, or will-change on ancestors
  • Use DevTools 3D view (Chrome) to visualize stacking contexts
  • Check if the competing element is in a different stacking context
  • Consider using isolation: isolate to explicitly control context boundaries
06

Best Practices

Managing z-index at scale requires discipline. Without a system, you end up with z-index wars where values escalate to absurd numbers. Use a token-based approach with defined layers, avoid creating unnecessary stacking contexts, and use isolation: isolate to contain z-index scope intentionally.

z-index-system.csscss
/* ✅ Define z-index layers as CSS custom properties */
:root {
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-overlay: 300;
  --z-modal: 400;
  --z-popover: 500;
  --z-toast: 600;
  --z-tooltip: 700;
}

.dropdown-menu {
  position: absolute;
  z-index: var(--z-dropdown);
}

.modal-backdrop {
  position: fixed;
  z-index: var(--z-overlay);
}

.modal {
  position: fixed;
  z-index: var(--z-modal);
}

.toast {
  position: fixed;
  z-index: var(--z-toast);
}

/* Use isolation to prevent z-index leaking */
.card {
  isolation: isolate; /* Creates stacking context without side effects */
  /* Children's z-index is now scoped to this card */
}

/* ❌ Avoid: z-index arms race */
.header { z-index: 999; }
.dropdown { z-index: 9999; }
.modal { z-index: 99999; }
.tooltip { z-index: 999999; } /* Where does it end? */

The isolation: isolate property is underused but powerful — it creates a stacking context without any visual side effects (unlike transform: translateZ(0) or opacity: 0.99). Use it to intentionally scope z-index within a component so it doesn't interfere with the rest of the page.

07

Why Interviewers Ask This

z-index and stacking contexts are one of the most misunderstood CSS concepts. Interviewers ask this to see if you understand why z-index 'doesn't work' sometimes (stacking context trap), know what properties create stacking contexts (not just position + z-index), can debug layering issues systematically, and have strategies for managing z-index at scale. It separates developers who understand the CSS rendering model from those who just increase z-index until it works.

Quick Revision Cheat Sheet

z-index requires: position: relative/absolute/fixed/sticky — ignored on static elements

Stacking context: Self-contained layer group — children's z-index only competes within it

Common creators: position + z-index, transform, opacity < 1, filter, fixed/sticky

The trap: Child can never escape parent's stacking context — z-index: 99999 won't help

isolation: isolate: Creates stacking context with no visual side effects — use for scoping

Best practice: Define z-index layers as tokens/variables — avoid arbitrary escalating values