Skip to content We're live on Product Hunt today Upvote
install

notes 6 min layout

note

Why z-index: 9999 does not put your element on top

The number is almost never the problem. z-index: 9999 loses to z-index: 2 every day of the week, and it loses for a reason that is nowhere near the element you were editing.

Before anything else, one check that costs five seconds: z-index does nothing at all on an element whose position is static. That is the default. If the property is not being read, no value of it is going to help.

The exception is a flex or grid child, where z-index applies without positioning. It is the one place the property works on a static element, and it is a pleasant surprise exactly once.

Past that, raising the number stops helping, and it stops helping abruptly rather than gradually. The reason is that z-index does not sort an element against the page. It sorts it against its siblings, inside a box called a stacking context.

The stacking context is the whole answer #

A stacking context is a self-contained painting order. Everything inside one paints together, as a unit, at whatever position its root occupies in the parent context. Nothing inside can paint above something outside it.

Which produces the rule that explains almost every one of these bugs: a child of a context with z-index: 1 sits below a sibling of that context with z-index: 2 — at 9999, at 2147483647, at any number you can type.

the tooltip never wins, and the number is irrelevant
.card    { position: relative; z-index: 1; }
.overlay { position: relative; z-index: 2; }

.card .tooltip { position: absolute; z-index: 9999; }

/* .tooltip paints inside .card, so it paints under .overlay.
   The only number that matters here is the 1. */
.card .tooltip · z-index: 9999
.overlay · z-index: 2

.card { z-index: 1 } — the tooltip paints above .card { z-index: 1; opacity: 0.98 } — trapped

The same tooltip at z-index: 9999, twice. The only thing changing is opacity on its parent - and opacity below 1 opens a stacking context, which puts the tooltip under an overlay it used to sit above.

So the element you are editing is rarely the one deciding. The question is which ancestor opened a context, and where that ancestor sits among its own siblings.

It is worth being precise about what a context does to the numbers inside it, because the mental model people carry is usually a bigger number wins rather than the true one, which is closer to a version scheme. 1.9999 is still less than 2. The ancestor is the integer part.

What creates one is a longer list than anybody remembers #

The familiar case is a positioned element with a z-index other than auto. The rest is where the surprises live:

opacity
Any value below 1. A hover state at 0.98 opens a context for the whole subtree.
transform
Any value other than none — including translateZ(0), added for the compositor by someone not thinking about paint order at all.
filter, backdrop-filter
Any value. A blur behind a sticky header is the usual arrival route.
mix-blend-mode
Anything other than normal.
isolation: isolate
The only one on this list whose entire purpose is to do it, and therefore the only one that is never a surprise.
contain
paint, layout, strict or content.
will-change
Naming any property above. A hint about performance that changes correctness.
position: fixed
On its own, with no z-index involved at all.

This is why the bug so often arrives with a commit that has nothing to do with layering. A card gets a hover opacity, a section gets a transform, and a dropdown three levels down that worked yesterday is trapped. Nothing about the dropdown changed.

The move that actually finds it #

Do not start at the element. Start above it and work down:

  1. Walk from the trapped element up to the root, and at every ancestor ask the one question: does this open a stacking context?
  2. The first ancestor that does is your ceiling. Nothing below it can ever paint above something outside it.
  3. Find where that ceiling sits among its own siblings. That comparison — not the one you were editing — is the one deciding.
  4. Fix it there: raise the ceiling, or move the element out from under it, and put the child back to a number a person can read.

Doing that by hand means reading computed styles for eight properties on every ancestor, which is precisely why it turns into raising the number instead. It is worth doing properly once, because the answer is nearly always a single ancestor with a property nobody associates with layering.

The four fixes, worst to best #

Raise the number
Works only when the two elements are already in the same context, which is the case you did not have. It is the fix that produced 9999 in the first place - each round of it raises the ceiling nobody found.
Raise the ancestor
Correct, and usually a one-line change: put the z-index on the ceiling instead of on the child. The risk is that the ceiling now beats things it should not, so it moves the problem up a level rather than removing it.
Move the element out
Render the overlay as a sibling of the ceiling rather than a descendant. In a component framework that means a portal, and the reason portals exist is precisely this - not styling, paint order.
Use the top layer
For anything modal, this is the answer, and it is the only one that cannot regress. See below.

There is a fifth thing people try - position: fixed on the trapped element - and it is worth naming because it seems to work. A fixed element escapes scrolling ancestors, not stacking contexts. If any ancestor has a transform, filter or will-change, the fixed element is positioned against that ancestor instead of the viewport, and the bug becomes two bugs.

isolation: isolate, and the one time you want more of this #

Everything above treats a stacking context as an accident. isolation: isolate is the property that creates one on purpose, and it is the right tool in one specific situation: a component whose internal layering must not leak into the page it is dropped into.

a component that cannot fight with the page around it
.widget {
  isolation: isolate;
}

/* Everything inside .widget now sorts among itself.
   The page sorts .widget as one thing.
   Nothing inside can reach past it, in either direction. */

That is the same mechanism causing every bug in this article, aimed deliberately. A stacking context is not a hazard; an accidental one is. The difference is whether you knew you were opening it.

The top layer ignores all of it #

A <dialog> opened with showModal(), an element with the popover attribute, and anything in fullscreen do not participate in this system at all. They paint in the top layer: above every stacking context on the page, in the order they were opened.

no z-index anywhere, and nothing can cover it
<div popover id="menu"></div>
<button popovertarget="menu">Open</button>

No z-index competes with the top layer and none is needed on it. Which makes it the real fix for the case that produces 9999 in the first place — a modal, a tooltip, a menu that has to escape whatever it is nested in. Not a bigger number. A different layer.

Written by Ján Turský

Building LoupeKit and other browser tools out of Bratislava, under Apptiary.

tools in this note