RizTech Academy logo
RizTech Academy
LayoutLesson 5 of 625 min

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:

  1. The root element's background
  2. Non-positioned block elements, in DOM order
  3. Floated elements
  4. Inline content
  5. 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:

  1. Raise the ancestor's z-index — put the header above main.
  2. Move the element out of the trapping ancestor, higher in the DOM.
  3. 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

  1. Set z-index: 10 on a static element and confirm it does nothing. Read the Computed panel and note that it still says 10. Then add position: relative.
  2. Set z-index: 5 on a direct child of a flex container with no position and confirm it works.
  3. Put a position: relative on one of three plain divs and watch it jump in front.
  4. Reproduce the trapped dropdown exactly: header at z-index: 1, dropdown at z-index: 9999, main at z-index: 2.
  5. Fix it by raising the header. Then instead fix it by moving the dropdown out.
  6. Add opacity: 0.99 to an ancestor of a working z-index and watch it break.
  7. Do the same with transform: translateY(0), then with filter: blur(0).
  8. Put a position: fixed header inside a transformed ancestor and watch it stop being fixed.
  9. Run the console loop above on the broken case and confirm it names the culprit.
  10. Add isolation: isolate to a card with an internal z-index: 2 and confirm the 2 cannot affect the page.
  11. Define the six --z-* custom properties and convert every z-index on one of your pages to them. Count how many were arbitrary.
  12. Stack a caption over an image using one grid cell and no z-index.
  13. Put a decorative shape at z-index: -1 behind a parent that has a background colour, then remove the background.
  14. Open a real site with a sticky header and a dropdown, and work out its stacking plan from devtools.

Official documentation

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