Mastering CSS z-index: Stacking Contexts, isolation, and Layering Bugs
You add z-index: 10 to a dropdown. It stays hidden. You try 100, then 999, then 9999. Still hidden. Somewhere
in the codebase, an element with a modest z-index: 2 keeps painting on top of it, and no number seems big enough to
win.
This is one of the most common frustrations in CSS, and it has nothing to do with the size of the number. z-index
follows a small set of rules that are rarely taught together: which elements it applies to, the order the
browser paints things in, and — most importantly — stacking contexts, the invisible groups that decide who is
allowed to compete with whom.
In this guide, we'll build that model from the ground up:
- How the browser layers elements without any
z-index - What
z-indexdoes, and why it sometimes does nothing at all - Stacking contexts: what they are, what creates them, and why
z-index: 9999loses to2 - How negative
z-indexreally behaves isolation: isolate— the cleanest way to create a stacking context on purpose- A scalable
z-indexsystem for production code, plus the top layer, which skips the whole fight - A repeatable way to debug any layering bug
The Third Dimension of a Web Page
A web page looks flat, but the browser treats it as a stack of layers along a z-axis that points out of the screen, towards you. When two elements overlap, the one painted later ends up closer to you, covering the one painted earlier.
So every layering question is really a question about painting order: in what order does the browser paint
these elements? z-index is one input to that order — but not the only one, and not the first one.
Painting Order Without z-index
Even with no z-index anywhere, the browser follows a fixed order. Two rules cover most everyday cases:
- Later in the source paints on top. Among similar elements, the one that comes later in the HTML covers the one that comes earlier.
- Positioned elements paint above non-positioned ones. An element with
positionset torelative,absolute,fixed, orstickypaints above ordinary in-flow content, no matter where it sits in the source.
<div class="box one">One</div>
<div class="box two">Two</div>
<div class="box three">Three</div>.box {
block-size: 4rem;
margin-block-end: -2rem; /* pull each box up so it overlaps the one before */
background: white;
}
.one,
.two {
position: relative;
}Boxes one and two are both positioned, so they follow source order: two covers one. Box three comes last in the source, yet box two still paints on top of it, because three isn't positioned.
The Full Painting Order
Those two rules are shortcuts for the complete algorithm. Within a single group (we'll name these groups shortly), the browser paints in seven layers, from the back to the front:
Visual Illustration
- The background and borders of the element that owns the group
- Positioned descendants with a negative
z-index, lowest value first - Block-level boxes in normal flow (not positioned)
- Floats (not positioned)
- Inline content — text, inline images, and
inline-blockboxes - Positioned descendants with
z-index: autoor0, in source order — along with stacking contexts created by properties such asopacityandtransform - Positioned descendants with a positive
z-index, lowest value first
Notice where z-index fits in: it only moves elements into layer 2 (negative values) or layer 7 (positive values).
Everything else is decided by the element's type and its position in the source.
The layers also explain a quirk you may have seen: when two ordinary blocks overlap, the text of the first block can show through the background of the second. Both backgrounds are painted in layer 3, before any text is painted in layer 5.
z-index Basics
The Syntax
.element {
position: relative; /* required — see below */
z-index: 1;
}z-index accepts two kinds of values:
auto(the default) — the element paints on its group's base level, and it does not start a new group.- An integer — positive, negative, or zero. Higher values paint closer to you. Units and decimals aren't allowed,
so
z-index: 1.5andz-index: 10pxare invalid and ignored.
When two elements have the same z-index, the tie is broken by source order: the later element paints on top.
z-index Needs a Positioned Element
This is the first rule that trips people up: z-index has no effect on an element with position: static, which
is the default for every element.
/* ❌ Ignored: the element isn't positioned */
.badge {
z-index: 10;
}
/* ✅ Works */
.badge {
position: relative;
z-index: 10;
}position: relative is the usual choice when you only need z-index, because it keeps the element exactly where it
was in the layout.
The Exception: Flex and Grid Items
There's one important exception. Children of a flex or grid container respect z-index even when they're static.
.toolbar {
display: flex;
}
.toolbar .active-tab {
z-index: 1; /* works — no position needed */
}This is handy for overlapping cards in a grid or tabs in a flex row, but it also explains a confusing situation: the
same z-index rule can "work" in one component and "fail" in another, simply because one parent uses
display: flex and the other doesn't.
z-index: auto vs. z-index: 0
On a positioned element, auto and 0 paint at the same level (layer 6 above). The difference is invisible
until the element has children with their own z-index:
z-index: auto→ the element's children compete with the rest of the page.z-index: 0→ the element creates a new group, and its children compete only with each other.
That "new group" is a stacking context, and it's the key to everything that follows.
Stacking Contexts: The Key to Everything
A stacking context is a group of elements that the browser layers together, as one unit. Inside the group,
z-index values sort the children. Outside the group, the whole group is placed as if it were a single element,
using the z-index of the element that created it.
The <html> element is the root stacking context, so every page has at least one. Many properties create more,
nested inside it.
The Version-Number Mental Model
The easiest way to reason about nested stacking contexts is to think of them as version numbers.
Consider this page:
<header class="site-header">…</header>
<main class="content">
<div class="modal">…</div>
</main>.site-header {
position: sticky;
top: 0;
z-index: 10;
}
.content {
position: relative;
z-index: 1;
}
.modal {
position: fixed;
inset: 0;
z-index: 9999;
}The modal has z-index: 9999, yet the sticky header paints on top of it. Here's why:
.contenthasposition: relativeandz-index: 1, so it creates a stacking context.- The modal lives inside
.content, so its9999only ranks it inside that context. Its effective position is1.9999— "version 1, patch 9999". - At the root, the browser compares only
.site-header(10) and.content(1). Since10 > 1, the header paints over all of.content— including the modal.
Visual Illustration
No value of the modal's z-index can fix this. 1.99999999 is still less than 10.
See It for Yourself
The Stacking contexts tab of the playground further down is real HTML and CSS, so the browser
decides what paints on top. It starts in the broken state: a tooltip with z-index: 9999 hidden under Card B, which
has z-index: 2.
Open it and try these, one at a time:
- Drag the tooltip's
z-indexanywhere between0and9999. Nothing changes — it's trapped inside Card A. - Set Card B's
z-indexto0(or Card A's to3). Now Card A outranks Card B, and the tooltip comes along with it. Try1as well: the two cards tie, so Card B wins again simply because it comes later in the source. - Put both cards back to their starting values (Card A
1, Card B2), then set Card A'sz-indextoauto. Card A no longer creates a stacking context, so the tooltip competes directly with Card B — and9999wins. - Keep Card A at
auto, and pick a value from Card A also has. Each one quietly creates a stacking context, and the tooltip is trapped again.
Step 4 is the one that catches experienced developers: opacity, transform, filter, and will-change all
create stacking contexts, even when they don't visibly change anything. A fade-in effect or a "GPU acceleration"
hack can break a dropdown's layering three components away.
What Creates a Stacking Context
Here are the common triggers. Any one of them is enough.
| Trigger | Example |
|---|---|
| The root element | <html> |
position: relative or absolute with a z-index other than auto | position: relative; z-index: 0; |
position: fixed or sticky (always, with any z-index) | position: sticky; top: 0; |
A flex or grid item with a z-index other than auto | z-index: 1; on a child of display: flex |
opacity less than 1 | opacity: 0.99; |
transform, translate, rotate, scale other than none | transform: translateZ(0); |
filter or backdrop-filter other than none | filter: blur(0); |
perspective, clip-path, or mask other than none | clip-path: inset(0); |
mix-blend-mode other than normal | mix-blend-mode: multiply; |
isolation: isolate | isolation: isolate; |
will-change naming any property above | will-change: transform; |
contain including layout or paint (or strict/content) | contain: paint; |
container-type: size or inline-size | container-type: inline-size; |
view-transition-name other than none | view-transition-name: hero; |
Elements in the top layer, and their ::backdrop | <dialog> opened with showModal() |
Two entries deserve a special mention:
position: stickyandposition: fixedalways create a stacking context. A sticky header is therefore a sealed group — any dropdown inside it can only be as high as the header itself.container-typeis new enough that many developers don't expect it here. Turning a card into a container query container also seals its layering.
Negative z-index
A negative z-index places an element in layer 2 of its stacking context: above the context's own background,
but below everything else in it, including normal text.
The surprise is the phrase "its stacking context". If the element's parent doesn't create one, the negative element isn't ranked inside the parent at all — it falls back to the nearest ancestor that does, and slides behind the parent's background.
.parent {
position: relative;
background: white;
}
.parent .decoration {
position: absolute;
z-index: -1; /* goes behind .parent's white background! */
}To watch it happen, open the Negative z-index tab of the playground and switch the parent between its three settings.
With z-index: auto, the red box hides behind the parent and only peeks out where it extends past the parent's
edge. Once the parent creates a stacking context — with z-index: 0 or isolation: isolate — z-index: -1 means
"the bottom of this parent's stack", and the box lands exactly where decorative layers usually belong: above the
parent's background, below its content.
This pattern is the standard way to build decorative shapes, glows, and pseudo-element backgrounds:
.card {
position: relative;
isolation: isolate; /* contain the negative layer */
background: var(--surface);
}
.card::before {
content: "";
position: absolute;
inset: -0.5rem;
z-index: -1;
background: linear-gradient(135deg, #ff6a00, #ee0979);
filter: blur(1rem);
}isolation: isolate — Stacking Contexts on Purpose
In the last two sections, we kept needing to create a stacking context without changing anything else about an element. Developers have long done this with hacks, each with a side effect:
| Hack | Side effect |
|---|---|
position: relative; z-index: 0; | Becomes the containing block for absolutely positioned descendants |
transform: translateZ(0); | Traps position: fixed descendants; may promote a GPU layer and blur text |
opacity: 0.99; | Slightly changes the element's appearance; confusing to future readers |
will-change: transform; | Reserves GPU memory; also traps position: fixed descendants |
isolation exists to do this one job cleanly:
.component {
isolation: isolate;
}It accepts only two values: auto (the default) and isolate. With isolate, the element creates a stacking
context and does nothing else. It doesn't need position or z-index, it doesn't change the containing block of
any descendant, it doesn't promote a GPU layer, and it reads as exactly what it is: "this component's layering stays
inside this component".
It's also safe to use everywhere: Chrome, Firefox, and Safari have supported isolation for over a decade, and
Edge has supported it since its move to Chromium.
Use Case 1: Self-Contained Components
The most valuable use of isolation is making components layering-safe. Inside an isolated component, you can
use small, local z-index values without worrying about the rest of the page:
.product-card {
display: grid;
position: relative;
isolation: isolate;
}
.product-card__image { z-index: 1; }
.product-card__overlay { position: absolute; inset: 0; z-index: 2; }
.product-card__badge { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 3; }That badge's z-index: 3 will never fight a site-wide header or modal, because it's only compared with the image and
overlay inside the same card. Your layering decisions become local, just like scoped styles. (The image is a grid
item, which is why it doesn't need its own position.)
Use Case 2: Containing mix-blend-mode
isolation was originally designed for blending. An element with mix-blend-mode blends with everything painted
beneath it in its stacking context — not just its siblings. If you want a group of elements to blend only with each
other, isolate the group. The Isolation & blending tab of the playground shows the
difference with three overlapping circles over light and dark stripes.
With isolation: auto, each circle blends with the stripes behind the group, so its colours change from stripe to
stripe. With isolate, the circles blend only with each other, and the finished group is laid over the stripes
normally. The same applies to blended logos, image overlays, and text effects built with mix-blend-mode.
One Detail Worth Knowing
An element that creates a stacking context through isolation (or opacity, transform, and the like) is painted
in layer 6 — the same level as a positioned element with z-index: 0. In rare layouts where an isolated element
overlaps following non-positioned siblings, for example via a negative margin, it will now paint on top of them.
If that happens, give the sibling position: relative so the two are ordered by source order again.
Try It Yourself
Every demo from the sections above lives in one playground, with a tab for each:
- Stacking contexts: the tooltip trapped inside Card A. Change both cards'
z-index, drag the tooltip's own value, and add the everyday properties that quietly create a stacking context. - Negative z-index: a decoration with
z-index: -1. Switch its parent betweenz-index: auto,z-index: 0, andisolation: isolateto see where it lands. - Isolation & blending: three
mix-blend-modecircles over light and dark stripes, with and withoutisolation: isolateon their group.
Under each demo, a readout explains why the browser painted what you see, and the panel below it shows the CSS for
exactly that state. The rules behind every demo live in styles.css, so you can edit them too and watch the preview
change — the readout only follows the controls.
z-index and Stacking Contexts Playground
Three tabs of real HTML and CSS: free a z-index: 9999 tooltip trapped inside its card, watch where a z-index: -1 decoration lands as its parent gains a stacking context, and keep mix-blend-mode inside a group with isolation: isolate — each with a readout explaining what the browser painted and the CSS for exactly that state.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>z-index and Stacking Contexts Playground</title> </head> <body> <main class="playground"> <header class="playground-header"> <h1>z-index and Stacking Contexts</h1> <p>Real HTML and real CSS, so the browser decides what paints on top. Change the controls, then read why it happened and copy the CSS for exactly what you see.</p> </header> <div class="tabs" role="tablist" aria-label="Demo" id="demo-switch"> <button class="tab tab--active" type="button" data-mode="stacking" role="tab" aria-selected="true">Stacking contexts</button> <button class="tab" type="button" data-mode="negative" role="tab" aria-selected="false">Negative z-index</button> <button class="tab" type="button" data-mode="blend" role="tab" aria-selected="false">Isolation & blending</button> </div> <!-- Stacking contexts: can the tooltip escape Card A? --> <div id="stacking-demo" class="tab-panel tab-panel--active"> <section class="demo-area" aria-label="Stacking context preview"> <div class="stage stage--plain" aria-hidden="true"> <div class="card card-a" id="card-a"> <strong>Card A</strong> <code id="card-a-code">z-index: 1</code> <div class="tooltip" id="tooltip"> Tooltip <code id="tooltip-code">z-index: 9999</code> </div> </div> <div class="card card-b" id="card-b"> <strong>Card B</strong> <code id="card-b-code">z-index: 2</code> </div> </div> </section> <fieldset class="control-group"> <legend>Controls</legend> <div class="fields-grid"> <div class="field"> <label for="card-a-z">Card A z-index</label> <select id="card-a-z"> <option value="auto">auto</option> <option value="0">0</option> <option value="1" selected>1</option> <option value="2">2</option> <option value="3">3</option> </select> </div> <div class="field"> <label for="card-a-trigger">Card A also has</label> <select id="card-a-trigger"> <option value="none" selected>nothing</option> <option value="opacity">opacity: 0.99</option> <option value="transform">transform: translateZ(0)</option> <option value="filter">filter: blur(0)</option> <option value="will-change">will-change: transform</option> <option value="isolation">isolation: isolate</option> </select> </div> <div class="field"> <label for="tooltip-z">Tooltip z-index (inside Card A)</label> <div class="range-row"> <span class="range-edge">0</span> <div class="range-track"> <input type="range" id="tooltip-z" min="0" max="9999" step="1" value="9999"> <output class="range-bubble" id="tooltip-z-value" for="tooltip-z">9999</output> </div> <span class="range-edge">9999</span> </div> </div> <div class="field"> <label for="card-b-z">Card B z-index</label> <select id="card-b-z"> <option value="auto">auto</option> <option value="0">0</option> <option value="1">1</option> <option value="2" selected>2</option> <option value="3">3</option> </select> </div> </div> </fieldset> <output class="readout verdict" id="stacking-verdict" aria-live="polite"></output> <output class="readout" id="stacking-css"></output> </div> <!-- Negative z-index: where does z-index: -1 go? --> <div id="negative-demo" class="tab-panel"> <section class="demo-area" aria-label="Negative z-index preview"> <div class="stage stage--hatched" aria-hidden="true"> <div class="parent" id="parent" data-context="auto"> <strong>Parent</strong> — this text is in-flow content, so it always paints above a negative layer of the same stacking context. <div class="decoration">z-index: -1</div> </div> <div class="spacer"></div> </div> </section> <fieldset class="control-group"> <legend>Controls</legend> <div class="field"> <label for="parent-context">Parent also has</label> <select id="parent-context"> <option value="auto" selected>z-index: auto</option> <option value="zero">z-index: 0</option> <option value="isolate">isolation: isolate</option> </select> </div> </fieldset> <output class="readout verdict" id="negative-verdict" aria-live="polite"></output> <output class="readout" id="negative-css"></output> </div> <!-- Isolation and blending: keeping mix-blend-mode inside a group --> <div id="blend-demo" class="tab-panel"> <section class="demo-area" aria-label="Isolation and blending preview"> <div class="stage stage--bands" aria-hidden="true"> <div class="group" id="group"> <div class="circle circle--1"></div> <div class="circle circle--2"></div> <div class="circle circle--3"></div> </div> </div> </section> <fieldset class="control-group"> <legend>Controls</legend> <div class="fields-grid"> <div class="field"> <label for="group-isolation">Group isolation</label> <select id="group-isolation"> <option value="auto" selected>auto</option> <option value="isolate">isolate</option> </select> </div> <div class="field"> <label for="blend-mode">Circles' mix-blend-mode</label> <select id="blend-mode"> <option value="multiply" selected>multiply</option> <option value="screen">screen</option> <option value="difference">difference</option> </select> </div> </div> </fieldset> <output class="readout verdict" id="blend-verdict" aria-live="polite"></output> <output class="readout" id="blend-css"></output> </div> </main> <script src="./index.js"></script> </body> </html>
Starting sandbox…
No console output yet.
No original version of /index.html to compare against.
Escaping the Fight: The Top Layer
Modern HTML has a separate layer that sits above the entire document, regardless of any z-index or stacking
context: the top layer.
Elements are placed in the top layer when you:
- Open a
<dialog>withshowModal() - Show an element with the
popoverattribute - Make an element fullscreen with
requestFullscreen()
<button popovertarget="account-menu">Account</button>
<div id="account-menu" popover>
<a href="/profile">Profile</a>
<a href="/sign-out">Sign out</a>
</div>const dialog = document.querySelector("dialog");
dialog.showModal(); // rendered above everything, with a ::backdropA top-layer element escapes every ancestor's stacking context — and overflow: hidden clipping, too. Within the top
layer, elements stack in the order they were opened, so a popover opened from inside a modal dialog correctly appears
above it. z-index has no effect on this order.
A z-index System for Production
On a real project, the worst z-index problems aren't the stacking contexts — they're the numbers. One developer
writes 999, the next writes 9999 to beat it, and a year later nobody knows what 99999 is guarding against.
The fix is the same as for colours and spacing: a named scale.
Global Layers with Custom Properties
:root {
--z-below: -1;
--z-base: 0;
--z-raised: 1;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
--z-tooltip: 600;
}
.site-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.toast-region {
position: fixed;
z-index: var(--z-toast);
}A few principles make the scale hold up:
- Leave gaps (
100,200, …) so a new layer can slot in between two existing ones without renumbering. - Name layers by purpose, not by value.
var(--z-modal)tells the next developer why;400doesn't. - Keep the global scale short. It should only cover layers that compete across the whole page: sticky headers, overlays, modals, toasts, and tooltips.
- Use local values inside isolated components. A card's internal
z-index: 1/2/3never needs a global token.
The Same Scale in SCSS
If your project uses Sass, a map with a guard function catches typos at build time:
@use "sass:map";
$z-layers: (
"below": -1,
"base": 0,
"raised": 1,
"dropdown": 100,
"sticky": 200,
"overlay": 300,
"modal": 400,
"toast": 500,
"tooltip": 600
);
@function z($layer) {
@if not map.has-key($z-layers, $layer) {
@error "Unknown z-index layer '#{$layer}'.";
}
@return map.get($z-layers, $layer);
}
.site-header {
position: sticky;
top: 0;
z-index: z("sticky");
}Debugging z-index Problems
When an element refuses to appear on top, guessing bigger numbers wastes time. Work through these steps instead.
A Step-by-Step Checklist
- Is the
z-indexapplied at all? In DevTools, check the element's Computed tab. Ifpositionisstaticand the element isn't a flex or grid item,z-indexis ignored. - Find the stacking contexts above it. Walk up the element's ancestors and note every one that creates a stacking context (see the table above, or use the snippet below).
- Find the shared context. Do the same for the element that's covering yours. The closest stacking context the two have in common is where the real comparison happens.
- Compare the representatives. In that shared context, compare the two ancestors that represent each side — not the elements themselves. That comparison is the one you need to change.
- Rule out clipping. If part of the element is cut off rather than covered, it's an
overflowproblem, not az-indexproblem. Noz-indexcan paint outside an ancestor'soverflow: hiddenwhen that ancestor contains the element's containing block.
Then fix the cause at the right level:
- Raise (or lower) the ancestor that represents the element in the shared context.
- Remove an accidental stacking context, such as a leftover
transformorwill-change. - Move the element out of the trapping context — with the top layer or a portal.
- Add
isolation: isolatewhere a component's internal layering is leaking out.
A DevTools Helper
Browsers don't list stacking contexts directly, so here's a small snippet that does. Paste it into the DevTools
console, select the problem element in the Elements panel, then run stackingContextsAbove($0):
function stackingContextsAbove(element) {
const results = [];
const paintProps = ["transform", "translate", "rotate", "scale", "filter", "backdropFilter", "perspective", "clipPath", "maskImage"];
for (let node = element.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node);
const parentDisplay = node.parentElement ? getComputedStyle(node.parentElement).display : "";
const isFlexOrGridItem = /flex|grid/.test(parentDisplay);
const reasons = [];
if (node === document.documentElement) reasons.push("root element");
if (style.zIndex !== "auto" && (style.position !== "static" || isFlexOrGridItem)) reasons.push(`z-index: ${style.zIndex}`);
if (style.position === "fixed" || style.position === "sticky") reasons.push(`position: ${style.position}`);
if (Number(style.opacity) < 1) reasons.push(`opacity: ${style.opacity}`);
for (const prop of paintProps) {
if (style[prop] && style[prop] !== "none") reasons.push(`${prop}: ${style[prop]}`);
}
if (style.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${style.mixBlendMode}`);
if (style.isolation === "isolate") reasons.push("isolation: isolate");
if (/transform|translate|rotate|scale|opacity|filter|perspective|clip-path|mask|isolation/.test(style.willChange)) reasons.push(`will-change: ${style.willChange}`);
if (/layout|paint|strict|content/.test(style.contain)) reasons.push(`contain: ${style.contain}`);
if (style.containerType === "size" || style.containerType === "inline-size") reasons.push(`container-type: ${style.containerType}`);
if (style.viewTransitionName && style.viewTransitionName !== "none") reasons.push(`view-transition-name: ${style.viewTransitionName}`);
if (reasons.length > 0) results.push({ element: node, reasons: reasons.join(", ") });
}
console.table(results);
return results;
}The table lists every ancestor that seals your element's layering, nearest first, along with the property responsible. Most of the time, the culprit is near the top of the list.
Common Pitfalls
1. z-index on a Static Element
z-index does nothing on an element with position: static unless it's a flex or grid item. Add
position: relative.
2. Raising the Child Instead of the Ancestor
If an ancestor creates a stacking context, the child's z-index can't help. Find the ancestor that represents it in
the shared context, and change that — or move the child out.
3. An Animation That Leaves a Stacking Context Behind
Entrance animations are a frequent source of accidental stacking contexts:
@keyframes slide-in {
from {
transform: translateY(1rem);
}
to {
transform: translateY(0);
}
}
.panel {
animation: slide-in 300ms ease-out both;
}Because of both (or forwards), the panel keeps transform: translateY(0) after the animation ends. That value
isn't none, so the panel remains a stacking context — and a containing block for fixed descendants — forever.
End the animation on transform: none instead, or drop the fill mode if the final state matches the element's
normal styles.
4. A position: fixed Modal Inside a Transformed Ancestor
A transform, filter, perspective, or will-change: transform on an ancestor doesn't just trap the modal's
z-index — it also becomes the containing block for position: fixed, so the "full-screen" modal is sized and
positioned relative to that ancestor. Render modals with <dialog> or a portal at the end of <body>.
5. Dropdowns Inside a Sticky Header
position: sticky always creates a stacking context, so a dropdown inside a sticky header can never paint above
something that outranks the header itself. Give the header a high enough layer (such as var(--z-sticky)), or render
the menu in the top layer with popover.
6. Treating Clipping as a Layering Problem
If the element is cut off at an ancestor's edge, it's being clipped by overflow, not covered. Changing z-index
won't help. Remove the overflow constraint, use overflow: clip with overflow-clip-margin where appropriate, or
move the element into the top layer.
7. The z-index Arms Race
Values like 9999, 99999, and 2147483647 (the largest value browsers can store) are a sign that the real problem
hasn't been found. Replace them with a named scale, and fix the stacking context that made them necessary.
Frequently Asked Questions
Wrapping Up
z-index is simple once you see the model behind it:
- The browser paints in a fixed order;
z-indexonly moves positioned elements (and flex or grid items) into the bottom or top band of their group. - Those groups are stacking contexts. A child's
z-indexis only compared inside its own context, which is why9999can lose to2. - Many properties create stacking contexts — including
opacity,transform,filter,will-change, andposition: sticky— often by accident. isolation: isolatecreates one on purpose, with no side effects. Use it to make components layering-safe.- For overlays, prefer the top layer, and keep everything else on a small, named
z-indexscale.
The next time an element refuses to come to the front, don't reach for another 9. Find its stacking context — the
answer is almost always one level up.
If the playground's opacity: 0.99 and transform triggers felt familiar, the
CSS Transitions Guide and
CSS Keyframes Guide cover the animation side of those properties in
depth.
This one is built around three new pieces: an exploded view of the seven painting layers, a version-number tree showing why 9999 can lose to 10, and a three-tab sandbox that runs real HTML and CSS rather than pictures of them — the trapped tooltip, the negative z-index box, and the blend-mode circles — each with a readout explaining what the browser just painted. Getting the playground's readout to agree with the browser in every combination — ties, z-index: 0 versus auto, an opacity of 0.99 that changes almost nothing on screen yet everything underneath — meant re-deriving the painting order more times than I'd like to admit. I hope the next time a 9999 refuses to budge, you go looking for its parent instead of adding another 9. Thanks for stacking up with me. 🥞