z-index, and why it sometimes does nothing
You have a dropdown appearing behind the content below it. You set
z-index: 9999. Nothing happens. You try 999999. Still nothing.
This is the layout bug a beginner genuinely cannot debug, because the rule that explains it is invisible in the CSS you are looking at. It is worth twenty minutes.
First: z-index needs position
.dropdown {
z-index: 10; /* ignored — the element is still position: static */
}
z-index does nothing on a position: static element. That is the answer about a
third of the time, and it is the first thing to check.
And devtools will not tell you. The Computed panel still reports z-index: 10,
because the value is valid and computed — it simply has no effect. There is no
strike-through and no warning triangle. So check position first, not z-index.
It works on relative, absolute, fixed and sticky — and, as an exception worth
knowing, on flex and grid items even when they are static. If your element is a
direct child of a flex or grid container, z-index works with no position at all.
The default stacking order
With no z-index anywhere, the browser paints in this order, back to front:
- The root element's background
- Non-positioned block elements, in DOM order
- Floated elements
- Inline content
- Positioned elements (and flex/grid items with a
z-index), in DOM order
Two consequences worth having.
Any positioned element sits above every non-positioned one, regardless of DOM
order. So adding position: relative to something can make it jump in front of a
neighbour for no reason you asked for.
Among positioned elements with no z-index, later HTML wins. So a dropdown in
your header is behind the <main> that follows it, and the cheapest fix is often not
z-index at all — it is moving the element later in the HTML, or not nesting it
somewhere that traps it.
Stacking contexts: the thing that makes z-index: 9999 fail
A stacking context is a self-contained world for stacking. Inside it, children are
stacked against each other. Against the outside world, the whole context is stacked
as a single unit at its own z-index.
That is the entire explanation:
<header style="position: relative; z-index: 1">
<div class="dropdown" style="position: absolute; z-index: 9999">…</div>
</header>
<main style="position: relative; z-index: 2">…</main>
The dropdown's 9999 is compared only against its siblings inside the header. The
header as a whole is at z-index: 1, main is at 2, so everything in the header —
including your 9999 — is behind everything in main.
No number on the child can ever escape its parent's context. z-index: 9999999
is exactly as effective as z-index: 1. This is why the instinct to add zeroes never
works, and why so much CSS in the world contains a z-index: 99999 that is doing
nothing.
The fix is one of three things, and never a bigger number:
- Raise the ancestor's
z-index— put the header abovemain. - Move the element out of the trapping ancestor, higher in the DOM.
- Remove whatever created the unwanted stacking context.
What creates a stacking context
The root element always does. Beyond that, the list is longer than people expect, and
most of it has nothing to do with z-index:
position: relative | absolute; /* with a z-index other than auto */
position: fixed | sticky; /* ALWAYS, even with no z-index */
opacity: 0.99; /* any value less than 1 */
transform: translateY(0); /* any transform */
filter: blur(0); /* any filter */
backdrop-filter: blur(4px);
will-change: transform;
isolation: isolate;
contain: layout | paint;
mix-blend-mode: multiply; /* anything other than normal */
Plus flex and grid items with a z-index other than auto.
opacity, transform and filter are the ones that catch everybody. A fade-in
animation that sets opacity: 0.99 mid-transition creates a stacking context for as
long as the animation runs, so a dropdown flickers behind something only while
animating. A transform: translateY(0) added "for GPU acceleration" traps every
positioned child underneath it permanently.
This is also why position: fixed stops being fixed inside a transformed ancestor —
the same mechanism, from the other side.
Finding it
Devtools does not show you stacking contexts directly in every browser, so the reliable procedure is to walk up the tree.
Select the misbehaving element, then move up through its ancestors in the Elements
panel and check each one's Computed styles for position, z-index, opacity,
transform, filter, will-change, isolation and mix-blend-mode. The first
ancestor with any of those is the context your element is trapped in — and its
z-index is the only number that matters to the outside world.
Or, in the console, which is faster:
let el = document.querySelector(".dropdown");
while (el && el !== document.documentElement) {
const s = getComputedStyle(el);
if (s.position !== "static" || s.opacity !== "1" || s.transform !== "none" ||
s.filter !== "none" || s.zIndex !== "auto" || s.isolation !== "auto" ||
s.mixBlendMode !== "normal" || s.willChange !== "auto") {
console.log(el, {position: s.position, zIndex: s.zIndex, opacity: s.opacity,
transform: s.transform, filter: s.filter, isolation: s.isolation});
}
el = el.parentElement;
}
That prints every ancestor that could be creating a context, with the property that did it. Keep it — it turns a half-hour of guessing into five seconds.
Firefox's devtools are worth having for this: its Inspector marks elements that create a stacking context, which Chrome does not.
isolation: isolate
.card { isolation: isolate; }
Creates a stacking context without needing position or a z-index. That sounds
like the problem, and it is actually the cure: it lets a component contain its own
stacking deliberately, so its internal z-index: 2 cannot interfere with anything
outside and nothing outside can slide between its layers.
Use it on a component whose internals stack — a card with an overlay and a badge. Then the component as a whole has one position in the page's stacking order, and you never have to think about the page when working inside the card.
Managing z-index so this stops happening
The reason people end up at 9999 is having no plan. Name your layers once:
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky-header: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}
.dropdown { z-index: var(--z-dropdown); }
.site-header { z-index: var(--z-sticky-header); }
Gaps of 100 so you can insert something later. Six named layers is plenty for a site
this size, and a z-index that is not one of these is a bug or a deliberate
local-to-a-context number like 1 or 2.
Keep page-level values in this list and use small numbers inside a component. A
z-index: 2 inside an isolation: isolate card is fine and readable, because it
cannot leak.
And the best option remains not needing z-index at all. Two grid items in the
same cell stack in DOM order, so an image and a caption over it need no positioning
and no z-index:
.hero { display: grid; }
.hero > * { grid-area: 1 / 1; }
Later HTML paints on top. That is often the whole answer.
Negative z-index
.decoration { position: absolute; z-index: -1; }
Puts the element behind its parent's background, which is occasionally what you want for a decorative shape — but note it goes behind the parent's background, not just behind its content, so a parent with a solid background hides it completely. And it cannot go behind the element that created its stacking context.
Check your work
The first thing to check. z-index is ignored on position: static — except on
flex and grid items — and devtools still shows the value, with no warning, so check
position rather than z-index.
Where positioned elements sit by default. Above every non-positioned element, regardless of DOM order.
What decides between two positioned elements with no z-index. DOM order — later
wins.
Why z-index: 9999 fails. It is compared only inside its own stacking context;
the whole context stacks at the ancestor's value.
The three real fixes. Raise the ancestor, move the element up the DOM, or remove what created the context. Never a bigger number.
The three properties that create a context unexpectedly. opacity below 1, any
transform, any filter.
Why position: fixed breaks inside a transformed ancestor. The same mechanism.
What position: fixed and sticky do about contexts. They always create one,
even with no z-index.
What isolation: isolate is for. Containing a component's own stacking
deliberately, with no position needed.
How to find the trapping ancestor. Walk up the tree checking position,
z-index, opacity, transform, filter, isolation — or run the console loop.
How to stack without z-index. Two grid items in one cell; later HTML paints on
top.
What negative z-index goes behind. The parent's background, not just its
content.
Practice
- Set
z-index: 10on a static element and confirm it does nothing. Read the Computed panel and note that it still says 10. Then addposition: relative. - Set
z-index: 5on a direct child of a flex container with nopositionand confirm it works. - Put a
position: relativeon one of three plain divs and watch it jump in front. - Reproduce the trapped dropdown exactly: header at
z-index: 1, dropdown atz-index: 9999,mainatz-index: 2. - Fix it by raising the header. Then instead fix it by moving the dropdown out.
- Add
opacity: 0.99to an ancestor of a workingz-indexand watch it break. - Do the same with
transform: translateY(0), then withfilter: blur(0). - Put a
position: fixedheader inside a transformed ancestor and watch it stop being fixed. - Run the console loop above on the broken case and confirm it names the culprit.
- Add
isolation: isolateto a card with an internalz-index: 2and confirm the 2 cannot affect the page. - Define the six
--z-*custom properties and convert everyz-indexon one of your pages to them. Count how many were arbitrary. - Stack a caption over an image using one grid cell and no
z-index. - Put a decorative shape at
z-index: -1behind a parent that has a background colour, then remove the background. - Open a real site with a sticky header and a dropdown, and work out its stacking plan from devtools.
Official documentation
- MDN — Stacking context — The full list of what creates one, which is longer than this lesson's.
- MDN — Using z-index — Worked examples of the default painting order, with diagrams.
- MDN — isolation — Creating a stacking context deliberately, without
position. - W3C — CSS Positioned Layout Level 3 — The specification, for when you need the exact painting order.
Next: rebuilding three real layouts from scratch.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship