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 |
Recommended z-index Scale
| 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 |
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.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); }
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 |