Z-index & Stacking Contexts
Sooner or later every web developer writes z-index: 9999 and watches it do nothing. The dropdown still hides behind the next card, or a decoration placed behind a heading disappears. The number is never the problem: the browser follows layering rules you have not met yet.
In this lesson you will learn the browser's painting order, what z-index really does, what creates a stacking context, how to use isolation and the top layer, and a checklist for fixing "z-index not working". You should know the position values from Positioning first.
How the browser layers a page
Think of the screen as a pile of paper. The browser paints from the bottom of the pile to the top, so whatever is painted last appears in front. Inside one group (a stacking context, explained below), the order from back to front is:
- The background and border of the element that owns the group.
- Positioned elements with a negative
z-index. - Normal block boxes in the flow, in HTML order.
- Floated boxes.
- Inline content: text, images, inline-blocks.
- Positioned elements with
z-index: autoor0, in HTML order. - Positioned elements with a positive
z-index, lowest number first.
Two rules explain most of what you see:
- A positioned element (any
positionexceptstatic) paints above normal, non-positioned elements, even if it comes earlier in the HTML. - When two elements are at the same level, the one later in the HTML paints on top.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Painting order</title>
<style>
body { font-family: system-ui, sans-serif; padding: 1.5rem; }
.tile { width: 12rem; height: 6rem; display: grid; place-content: center; color: white; font-weight: 600; border-radius: 10px; }
.tile + .tile { margin-top: -2rem; }
.a { background: #1d4ed8; position: relative; } /* positioned, first in HTML */
.b { background: #047857; margin-left: 2rem; } /* normal, not positioned */
.c { background: #b91c1c; margin-left: 4rem; } /* normal, not positioned */
</style>
</head>
<body>
<div class="tile a">A: positioned</div>
<div class="tile b">B: static</div>
<div class="tile c">C: static</div>
</body>
</html>C covers the bottom of B because it comes later in the HTML. But A covers the top of B, even though A comes first, because A is positioned. Now add position: relative to .b and run it again: B is positioned too and comes later than A, so it covers A, and it also rises above C, which is still static.
What z-index does
z-index moves an element up or down within its group. Higher numbers are closer to you. It works on:
- positioned elements (
relative,absolute,fixed,sticky); - flex items and grid items, even without
position.
On a normal block with position: static that is not a flex or grid item, z-index does nothing at all. That is the first reason it "does not work".
The default value is auto, which means "no special layer, stay in the normal painting order". A negative value sends a positioned element behind the normal content of its group, which is useful for decorations.
Stacking contexts
A stacking context is a group of layers that the browser paints as one unit. Imagine a sealed envelope full of paper sheets: you can reorder the sheets inside the envelope as much as you like, but the whole envelope still sits at one place in the pile. A sheet inside an envelope can never come above another envelope that sits higher.
That is why a child with z-index: 9999 can stay behind something with z-index: 2: its number is only compared with its siblings inside the same envelope.
These properties create a new stacking context:
| Property | Creates a stacking context when |
|---|---|
position: relative or absolute | z-index is not auto |
position: fixed or sticky | always |
| flex or grid item | z-index is not auto |
opacity | less than 1 |
transform, translate, rotate, scale | not none |
filter, backdrop-filter, clip-path, mask | not none |
mix-blend-mode | not normal |
isolation | isolate |
will-change | names one of the properties above |
contain | layout, paint, strict or content |
container-type | size or inline-size |
The <html> element is always the outermost stacking context. Notice how many of these are visual effects: a card that fades in or lifts on hover becomes its own group without you asking.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Trapped dropdown</title>
<style>
body { font-family: system-ui, sans-serif; padding: 1rem; }
.cards { display: grid; gap: 0.75rem; max-width: 20rem; }
.card {
padding: 1rem;
background: white;
border: 1px solid #cbd5e1;
border-radius: 10px;
animation: rise 0.5s ease-out both; /* the final translate creates a stacking context */
}
@keyframes rise {
from { opacity: 0; translate: 0 1rem; }
to { opacity: 1; translate: 0 0; }
}
.actions { position: relative; }
.menu {
position: absolute;
top: 1.8rem;
left: 0;
z-index: 999;
margin: 0;
padding: 0.5rem 1rem;
list-style: none;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 8px;
}
/* The fix: raise the whole card that holds the open menu */
/* .card:first-child { z-index: 1; } */
</style>
</head>
<body>
<div class="cards">
<article class="card">
<strong>Order #1042</strong>, Aarav, ₹1,299
<div class="actions">
<span>Options:</span>
<ul class="menu"><li>Track</li><li>Return</li><li>Download invoice</li></ul>
</div>
</article>
<article class="card"><strong>Order #1043</strong>, Priya, ₹649</article>
</div>
</body>
</html>The yellow menu has z-index: 999, yet the second card covers it. Each card became a stacking context because the animation leaves translate applied, so the menu is sealed inside the first card, and the second card is painted later. Remove the comment marks around the last rule and run it again. The card is a grid item, so z-index: 1 works without position, and the whole first card now sits above the second. In a real app you would raise the card whose menu is open, for example with .card:focus-within.
isolation: isolate
Sometimes you want a new stacking context. isolation: isolate creates one and does nothing else: no visual change, no positioning.
A classic use is a decoration placed behind an element's text with z-index: -1. Without a stacking context on the parent, the decoration falls behind the parent's own background (or even the page background) and vanishes. With isolation: isolate on the parent, -1 means "behind my text, but still inside my box".
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>isolation</title>
<style>
body { font-family: system-ui, sans-serif; padding: 1.5rem; }
.panel { background: #f1f5f9; padding: 1.5rem; margin-bottom: 1rem; border-radius: 12px; }
.highlight { position: relative; display: inline-block; font-size: 2rem; margin: 0; }
.highlight::before {
content: "";
position: absolute;
inset: 55% -0.25rem 0.1rem;
background: #fde047;
z-index: -1; /* behind the text */
border-radius: 4px;
}
.fixed { isolation: isolate; }
</style>
</head>
<body>
<div class="panel"><h2 class="highlight">Admissions open</h2> (no isolation: the yellow bar is lost behind the grey panel)</div>
<div class="panel fixed"><h2 class="highlight">Admissions open</h2> (isolation: isolate on the panel)</div>
</body>
</html>In the first panel, the yellow bar went all the way back, behind the grey panel background. In the second, the panel is a stacking context, so the bar stays inside it, between the panel's background and the heading's text.
isolation: isolate is also a good habit on reusable components: their internal z-index values cannot leak out and fight with the rest of the page.
The top layer
Modal dialogs and popovers do not need z-index at all. A <dialog> opened with showModal(), an element using the popover attribute, and an element in full-screen mode are placed in the top layer: a special layer above the whole page, above every stacking context, whatever its z-index. See Details, Summary & Dialog and Popover API.
Debugging "z-index not working"
Work through these questions in order:
- Can this element take a
z-index? It must be positioned, or a flex or grid item. Otherwise the value is ignored. - Which stacking context is it in? Walk up through its ancestors in the browser's developer tools. Look for
opacitybelow 1,transform,translate,filter,will-change,isolation, sticky or fixed positioning, orpositionwith az-index. - Compare the right elements. Find the two ancestors that sit side by side in the same stacking context. Their order decides the result, not the children's numbers.
- Is it hidden, not covered? An ancestor with
overflow: hiddenorclip-pathcuts off a dropdown. That looks like a layering problem, but noz-indexcan fix it. - Should it be in the top layer? Use
<dialog>orpopoverfor modals, menus and toasts.
Microsoft Edge's developer tools include a 3D View with a Z-index tab that draws the stacking contexts of a page in 3D, which helps on big pages.
Keep your values small and planned. Many teams store them as custom properties:
:root {
--z-dropdown: 10;
--z-sticky-header: 20;
--z-toast: 30;
}
.site-header { position: sticky; top: 0; z-index: var(--z-sticky-header); }
.menu { position: absolute; z-index: var(--z-dropdown); }Common mistakes
z-indexon a static element. It is ignored unless the element is positioned or a flex or grid item.- Raising the child instead of the ancestor. A child cannot escape its stacking context; raise the ancestor that forms it.
- Forgetting that effects create contexts.
opacity,transform,filterandwill-changeall create stacking contexts. z-index: -1that disappears. Give the parentisolation: isolateso the element stays inside it.
Key points
- Within a group, positioned elements paint above normal ones, and later HTML paints above earlier HTML.
z-indexworks on positioned elements and on flex and grid items; higher numbers come forward.- A stacking context is a sealed group: children are only ordered inside it.
opacity, transforms, filters,isolation, fixed and sticky positioning and more create stacking contexts.isolation: isolatecreates a context on purpose, which keepsz-index: -1decorations and component layers contained.- Dialogs and popovers use the top layer, above everything, without any
z-index.
Exercise
A product page for a Kochi spice shop has a sticky header with z-index: 20 and a grid of product cards that have filter: drop-shadow(...) applied. Each card has a "Share" menu with position: absolute; z-index: 50 that is hidden behind the next card. Write CSS that fixes the menus while keeping the header above the cards when the page scrolls. Then add a heading with a light-orange highlight behind its text using ::before and z-index: -1.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Spice shop</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; }
header { position: sticky; top: 0; z-index: 20; background: #14532d; color: white; padding: 0.75rem 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: 1rem; padding: 1rem; }
.card { background: white; padding: 1rem; border-radius: 10px; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 25%)); }
.card:focus-within, .card:hover { z-index: 1; } /* raise the card whose menu is in use */
.share { position: relative; }
.share ul { position: absolute; top: 100%; left: 0; z-index: 50; margin: 0; padding: 0.5rem 1rem; list-style: none; background: #fff7ed; border: 1px solid #fb923c; }
.title { position: relative; isolation: isolate; width: fit-content; margin: 1rem; }
.title::before { content: ""; position: absolute; inset: 50% -0.3rem 0; background: #fed7aa; z-index: -1; }
</style>
</head>
<body>
<header>Kochi Spice Market</header>
<h1 class="title">Fresh from Kerala</h1>
<div class="grid">
<article class="card">Black pepper, ₹320 <div class="share"><button>Share</button><ul><li>WhatsApp</li><li>Email</li></ul></div></article>
<article class="card">Green cardamom, ₹450</article>
</div>
</body>
</html>The cards are grid items, so z-index: 1 raises the active card's whole stacking context above its neighbours, but it stays below the header's 20 because both are compared in the root context. The heading isolates itself, so its -1 highlight stays behind its own text and not behind the page.
Quick Quiz
1. A <div> with position: static has z-index: 10. What happens?
z-index needs a positioned element, or a flex or grid item.
2. Which declaration does NOT create a stacking context?
Relative and absolute positioning only create a stacking context when z-index is not auto.
3. A menu with z-index: 999 is covered by the next card. Both cards have opacity: 0.95. What is the best fix?
The menu cannot leave its card's stacking context, so the cards themselves must be ordered.
4. Why does <dialog> opened with showModal() appear above everything without z-index?
The top layer is a separate layer above the whole document.
Learn CSS with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
