notes 6 min layout
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.
.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 { z-index: 1 } — the tooltip paints above .card { z-index: 1; opacity: 0.98 } — trapped
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.98opens a context for the whole subtree. - transform
- Any value other than
none— includingtranslateZ(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,strictorcontent.- will-change
- Naming any property above. A hint about performance that changes correctness.
- position: fixed
- On its own, with no
z-indexinvolved 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:
- Walk from the trapped element up to the root, and at every ancestor ask the one question: does this open a stacking context?
- The first ancestor that does is your ceiling. Nothing below it can ever paint above something outside it.
- Find where that ceiling sits among its own siblings. That comparison — not the one you were editing — is the one deciding.
- 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-indexon 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.
.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.
<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