z-index and Stacking Contexts

โ–ถ Try It Yourself

Have you ever added a high z-index to an element only to find it still appears behind another element? Or added a modal backdrop with z-index: 9999 that still gets covered? The answer is almost always stacking contexts โ€” one of the most misunderstood concepts in CSS. In this lesson you will learn exactly how z-index works, what creates a stacking context, and how to manage layering predictably in real projects.

How z-index Works

Concept Explanation
z-index Controls the “depth” order within the same stacking context โ€” higher value paints on top
Requires positioning z-index only has effect when position is not static (or on flex/grid items)
Stacking context An independent 3D layer where z-index values are evaluated โ€” children cannot escape it
Context boundary No child z-index can ever exceed its stacking context parent โ€” contexts are composited as a whole
Natural paint order Without z-index: back-to-front: block elements, floats, inline elements, positioned elements

What Creates a Stacking Context

Property Value That Triggers It
position + z-index Any positioned element with z-index other than auto
opacity Any value less than 1
transform Any value other than none
filter Any value other than none
isolation isolate
will-change Values like transform, opacity
Layer z-index Range Examples
Base content 0 Normal page content
Raised UI 10โ€“99 Sticky headers, floating action buttons
Dropdowns 100โ€“199 Nav dropdowns, autocomplete lists
Overlays 200โ€“299 Toast notifications, popovers
Modals 300โ€“399 Dialog backdrops and panels
System UI 400+ Cookie banners, critical alerts
Note: z-index values are evaluated within the same stacking context. If two stacking contexts are siblings, the one with the higher z-index on the context root wins โ€” regardless of what z-index values exist inside them. A z-index: 9999 inside a context with z-index: 1 will always be behind a context with z-index: 2, even if that context has elements with z-index: 1 inside.
Tip: Use the isolation: isolate property on component wrappers to deliberately create a stacking context. This prevents z-index values inside the component from competing with the rest of the page โ€” a clean way to scope component layering without worrying about global z-index conflicts.
Warning: Adding transform, opacity < 1, or filter to a parent element silently creates a new stacking context. This is the most common cause of “my z-index isn’t working” bugs โ€” a parent gained a CSS property that trapped its children’s z-index inside a new context.

Basic Example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>z-index and Stacking Contexts</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; padding: 40px; background: #f8fafc; }

    /* โ”€โ”€ Basic z-index demo โ”€โ”€ */
    .stack-demo { position: relative; height: 120px; margin-bottom: 48px; }

    .stack-box {
      position: absolute;
      width: 140px;
      height: 90px;
      border-radius: 10px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.8rem;
      font-weight: 700;
      color: white;
    }
    .box-a { background: #4f46e5; top: 0;   left: 0;   z-index: 1; }
    .box-b { background: #7c3aed; top: 20px; left: 60px;  z-index: 3; }
    .box-c { background: #0891b2; top: 40px; left: 120px; z-index: 2; }

    /* โ”€โ”€ Stacking context isolation โ”€โ”€ */
    .context-demo { display: flex; gap: 24px; margin-bottom: 32px; align-items: flex-start; }

    .context-a {
      position: relative;
      z-index: 1;             /* context A: paints behind context B */
      background: #ede9fe;
      padding: 16px;
      border-radius: 10px;
      border: 2px solid #7c3aed;
      width: 160px;
    }
    .context-a .inner {
      position: relative;
      z-index: 9999;          /* inside context A โ€” does NOT beat context B */
      background: #4f46e5;
      color: white;
      padding: 8px;
      border-radius: 6px;
      font-size: 0.75rem;
      text-align: center;
    }

    .context-b {
      position: relative;
      z-index: 2;             /* context B: paints on top of context A */
      background: #fef3c7;
      padding: 16px;
      border-radius: 10px;
      border: 2px solid #f59e0b;
      width: 160px;
    }
    .context-b .inner {
      position: relative;
      z-index: 1;             /* inside context B โ€” beats everything in context A */
      background: #f59e0b;
      color: white;
      padding: 8px;
      border-radius: 6px;
      font-size: 0.75rem;
      text-align: center;
    }

    /* โ”€โ”€ isolation: isolate demo โ”€โ”€ */
    .isolated-component {
      isolation: isolate;     /* new stacking context โ€” children stay inside */
      position: relative;
      background: white;
      border: 1px solid #e2e8f0;
      border-radius: 12px;
      padding: 20px;
      width: 260px;
    }
    .isolated-component .tooltip {
      position: absolute;
      top: -36px;
      left: 16px;
      z-index: 100;           /* high z-index stays scoped inside isolation context */
      background: #1e293b;
      color: white;
      padding: 6px 12px;
      border-radius: 6px;
      font-size: 0.75rem;
      white-space: nowrap;
    }

    .label { font-size: 0.8rem; font-weight: 600; color: #64748b; margin-bottom: 8px; }
  </style>
</head>
<body>

  <div class="label">z-index paint order: B (z:3) on top, C (z:2), A (z:1) behind</div>
  <div class="stack-demo">
    <div class="stack-box box-a">A z:1</div>
    <div class="stack-box box-b">B z:3</div>
    <div class="stack-box box-c">C z:2</div>
  </div>

  <div class="label">Stacking contexts: z-index:9999 inside context A loses to z-index:1 inside context B</div>
  <div class="context-demo">
    <div class="context-a">
      Context A (z:1)
      <div class="inner">z-index: 9999 โ€” LOSES</div>
    </div>
    <div class="context-b">
      Context B (z:2)
      <div class="inner">z-index: 1 โ€” WINS</div>
    </div>
  </div>

  <div class="label">isolation: isolate โ€” tooltip z-index scoped inside component</div>
  <div class="isolated-component">
    <div class="tooltip">Tooltip scoped by isolation: isolate</div>
    <p style="margin:0;font-size:0.875rem;color:#475569;">This component's z-index values do not compete with the rest of the page.</p>
  </div>

</body>
</html>

How It Works

Step 1 โ€” z-index Requires a Positioned Element

All three boxes have position: absolute โ€” without a non-static position, z-index is silently ignored. They share the same stacking context (the .stack-demo parent), so their z-index values are compared directly: 3 wins, then 2, then 1.

Step 2 โ€” Stacking Contexts Are Opaque Layers

Context A has z-index: 1 โ€” it is composited as a single unit behind Context B’s z-index: 2. The z-index: 9999 inside Context A has no power to exceed Context B because z-index values are only meaningful within the same context. Context A as a whole is lower than Context B as a whole.

Step 3 โ€” Opacity Creates a Stacking Context Silently

Adding opacity: 0.99 (or any value below 1) to any parent element silently creates a new stacking context. Child elements with high z-index values become trapped inside it. This is the #1 cause of mysterious z-index failures.

Step 4 โ€” isolation: isolate Creates an Explicit Context

isolation: isolate creates a stacking context on demand without any visual side effects โ€” no opacity change, no transform, no position needed. It is the cleanest way to scope a component’s z-index values so they do not interfere with the rest of the page.

Step 5 โ€” A Consistent z-index Scale Prevents Wars

When every team member uses the same named z-index scale (base, dropdown, modal, etc.) stored in CSS custom properties, z-index conflicts are almost impossible. Decide on a scale, document it, and enforce it in code review.

Real-World Example: z-index Scale with Custom Properties

/* z-index-system.css */
:root {
  --z-below:    -1;
  --z-base:      0;
  --z-raised:   10;
  --z-sticky:   20;
  --z-dropdown: 100;
  --z-overlay:  200;
  --z-modal:    300;
  --z-toast:    400;
  --z-tooltip:  500;
}

/* Site header */
.site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: var(--z-sticky);
  background: white;
  box-shadow: 0 1px 8px rgba(0,0,0,0.08);
}

/* Dropdown menu */
.dropdown-menu {
  position: absolute;
  top: 100%; left: 0;
  z-index: var(--z-dropdown);
  background: white;
  border-radius: 8px;
  border: 1px solid #e2e8f0;
  box-shadow: 0 8px 24px rgba(0,0,0,0.1);
  min-width: 200px;
}

/* Modal backdrop */
.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  background: rgba(0,0,0,0.5);
}

/* Modal panel on top of backdrop */
.modal-panel {
  position: fixed;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  z-index: calc(var(--z-modal) + 1);
  background: white;
  border-radius: 16px;
  padding: 40px;
  max-width: 480px;
  width: 90%;
}

/* Toast โ€” above modals */
.toast-container {
  position: fixed;
  bottom: 24px; right: 24px;
  z-index: var(--z-toast);
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Component isolation */
.datepicker-wrapper {
  position: relative;
  isolation: isolate;   /* tooltip/dropdown inside scoped โ€” won't conflict with page */
}

Common Mistakes

Mistake 1 โ€” z-index on a static element

โŒ Wrong โ€” z-index has no effect; element remains in natural paint order:

.overlay { z-index: 100; } /* position: static โ€” z-index ignored */

โœ… Correct โ€” add a non-static position:

.overlay { position: relative; z-index: 100; }

Mistake 2 โ€” transform accidentally trapping a child’s z-index

โŒ Wrong โ€” transform on parent creates stacking context; modal inside can’t escape:

.page-section { transform: translateY(0); } /* creates stacking context! */
.modal { position: fixed; z-index: 9999; }  /* trapped inside section context */

โœ… Correct โ€” move the modal outside the transformed ancestor in the HTML:

<div class="page-section">...</div>
<div class="modal">...</div> <!-- sibling, not child of transformed element -->

Mistake 3 โ€” Escalating z-index values without a system

โŒ Wrong โ€” z-index arms race with arbitrary values:

.header   { z-index: 100; }
.modal    { z-index: 9999; }
.nav      { z-index: 99999; }   /* escalates every time something breaks */

โœ… Correct โ€” define and use a named scale:

:root { --z-header: 20; --z-modal: 300; }
.header { z-index: var(--z-header); }
.modal  { z-index: var(--z-modal); }

▶ Try It Yourself

Quick Reference

Rule Detail
z-index requires position Must be relative, absolute, fixed, or sticky (or flex/grid item)
Values compared within context Higher value = closer to viewer within the same stacking context
Context is composited as one A context with z:1 is entirely behind a context with z:2 regardless of internal values
opacity < 1 creates context Even opacity: 0.999 traps children
transform creates context Any transform value โ€” the most common hidden source
isolation: isolate Explicit clean context creation โ€” no visual side effects

🧠 Test Yourself

A modal has z-index: 9999 but appears behind the page header. What is the most likely cause?





โ–ถ Try It Yourself