← All writing
Craft · · 7 min

z-index: 9999

z-index is a family tree, not a leaderboard. The fade-in from 2022 is what's actually causing your bug.

CSS

Everybody has written z-index: 9999 at least once, and I think we should talk about it.

You know the sequence. The modal is rendering behind the sticky header. So you give the modal a z-index of 100, because the header has 10 and 100 is more than 10. Nothing happens. You try 1000. Nothing happens. And somewhere around 9999 you’ve stopped debugging and started bargaining, which is how we end up with cookie banners in the wild sitting at 2147483647, the largest number a signed 32-bit integer can hold. Someone got there. Someone reached the actual ceiling of the datatype and shipped it.

The thing is, the number was never the problem, so no number is going to be the solution. z-index isn’t a global ranking. It never was. I went something like four years writing CSS professionally without anybody telling me that, and I don’t think I’m unusual.

It’s a family tree, not a leaderboard

Here’s the whole trick. z-index only ever compares you against your siblings, inside whatever stacking context you happen to be in. And a stacking context stacks as a single unit at its parent’s level. So if your modal lives inside some element that formed a stacking context back at z-index: 1, the entire modal, 9999 and all, is being placed at 1 relative to the header. Its internal number is real, it’s just being spent in a room nobody else is standing in.

The way I finally got it to stick was thinking about page numbers in a book. Chapter 2 can have a page 9999 if it wants. It still comes before chapter 3, page 1. The page number only orders you within your chapter, and the chapters order themselves, and no page has ever been promoted into a different chapter by being large.

The number was never the problem, so no number is going to be the solution.

Which reframes the bug, I think. When the number doesn’t work, that’s not a failure of the number, it’s information: somewhere up the DOM tree there’s an ancestor forming a context you didn’t know about. The bug is never where you’re typing. It’s three components up.

The list is longer than you want it to be

If forming a stacking context required something explicit, this would all be fine. position: relative plus a z-index does it, sure, and that one’s fair, you asked for layering and you got layering.

But the rest of the list is properties that have nothing to do with stacking as far as the person writing them is concerned. opacity below 1. transform, including the translateZ(0) somebody added in 2019 to “force GPU acceleration.” filter, backdrop-filter, mix-blend-mode, clip-path, mask. will-change, if you name any of those properties in it. contain. position: fixed and position: sticky, unconditionally. Grid and flex children, once they have a z-index. Off the top of my head that’s a dozen or so and I’m probably forgetting a few.

card.css
.card {
  opacity: 0.99;              /* someone's fade-in, three years ago */
  will-change: transform;     /* someone's hover lift, last spring */
}

.card__dropdown {
  position: absolute;
  z-index: 9999;              /* trapped, and doing nothing */
}
CSS

Nobody writes opacity: 0.99 while thinking about layering. That’s the whole problem. It’s a hover state, or a fade-in, or a blur on a header, and the consequence lands somewhere else entirely, often in a component authored by a different person in a different quarter. There’s no error, nothing goes red, the dropdown just quietly renders underneath the thing it’s supposed to sit on top of and everyone assumes the dropdown is broken.

Both Firefox and Chrome’s devtools will flag elements that create a stacking context, which I found out embarrassingly recently and which turns a 20-minute bisect into about four seconds of clicking up the tree. Worth going and finding where that lives in whichever browser you debug in.

“Just use a portal”

I know the counter here, and it’s a good one. Render your modal at the end of <body>, or use a React portal, or better, use the top layer: <dialog> with showModal(), or the popover API. Elements in the top layer sit above the entire page regardless of stacking context, which means the whole class of problem I’ve just spent 500 words on evaporates.

And yeah! That’s genuinely right, and I use it, and if you’re building an overlay today you should reach for it before you reach for a number. It’s the correct answer to the correct question.

I’d just say the modal is the loudest version of this bug, not the common one. The ones I actually hit are local: a tooltip clipped by its scroll container, a sticky table column that won’t sit over the row beneath it, a focus ring getting eaten by the sibling next to it, a dropdown inside a card. None of those want to be in the top layer. They want to be ordered correctly relative to three things near them, which is exactly what stacking contexts govern, so you still have to know how this works. The escape hatch handles the one case where “on top of literally everything” is the honest requirement.

What actually keeps it from happening

Two things, and neither is clever.

First, have about five layers and give them names. A handful of custom properties, one place, sorted.

tokens.css
:root {
  --z-raised: 1;
  --z-dropdown: 10;
  --z-header: 20;
  --z-overlay: 30;
  --z-toast: 40;
}
CSS

The gaps are deliberate, and the real value isn’t the tidiness, it’s that when somebody needs a layer that isn’t on the list, they have to come talk to you about it. That’s a five minute conversation about what’s supposed to sit over what. It is a much better conversation than the one where they type 10000 and move on and it becomes yours in a year.

Second, isolation: isolate. It creates a stacking context and does absolutely nothing else: no compositing cost, no visual change, no side effects. Put it at your component boundaries. It means the component’s internals can’t be interleaved by anything outside it and can’t leak out over things they shouldn’t, and it means the component gets to keep its own private little number scale starting from 1. Everything else on that list creates a context as a side effect of doing something visual. This is the one property that exists to do it on purpose, and I think it’s the most underused line in CSS.

Anyway, next time you’re mid-escalation and 9999 hasn’t worked, don’t go to 99999. Walk up the tree instead and find the opacity: 0.99 somebody added for a fade-in in 2022, because I promise you it’s there 🫠. The number was doing exactly what it was told the entire time. You were just yelling it in the wrong room.

I have written 9999 myself this year, for the record. Twice, in the same file.

Read similar posts
6 min

Style queries

Five surface variants, four child elements, and 20 selectors that existed only to tell a child which parent it was in. Style queries delete the whole grid of them. That isn't quite the same as saying you needed them.

6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.