← All writing
Craft · · 9 min

z-index: 9999

On the scroll reveal that caused it, the dozen properties that create a stacking context behind your back, and the number I replaced 9999 with.

CSS Debugging

A size dropdown on a client’s product grid was rendering underneath the row of products below it.

It already had z-index: 9999 on it when I opened the file. Not 100, not 1000. Somebody had gone straight to the ceiling, which told me they’d had the same afternoon I was about to have, because you know the sequence. The panel sits behind the thing next to it. So you give it 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. Lol, somebody got there. Somebody reached the actual ceiling of the datatype and released it.

The clue was that the same component was fine on the search results page. Same markup, same stylesheet, same dropdown, sitting over everything exactly as designed. Only the PLP was broken.

Which is the tell that the number was never the problem, so no number was going to be the solution. z-index isn’t a global ranking and never has been. I wrote CSS professionally for years before anybody told me that. I don’t think I’m unusual.

Chapter 2 can have a page 9999

Here’s the whole trick, and it’s one sentence long.

z-index only ever compares you against your siblings, inside whatever stacking context you happen to be sitting in. And a stacking context stacks as a single unit at its parent’s level. So if the dropdown lives inside an element that formed a context of its own, the entire dropdown, 9999 and all, is being placed at that element’s level relative to the cards around it. Its 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 inside your chapter, the chapters order themselves, and no page has ever been promoted into a different chapter by being large.

Which reframes the bug. When the number doesn’t work, that isn’t the number failing, it’s information. Somewhere up the tree there’s an ancestor forming a context you don’t know about. The bug is never where you’re typing. It’s 3 components up, somewhere you had no reason to look, which is the same shape as the sidebar that wouldn’t stick and cost me an hour back in 2017.

Go and click up the tree

The search I should have run first, and it takes seconds.

Both Firefox and Chrome badge an element in DevTools when it creates a stacking context. I found that out embarrassingly recently. It turns a 20-minute bisect into clicking up the tree until a badge appears. It’s the method I keep telling people I use and then don’t, because typing a bigger number is right there and costs nothing.

Two levels above the card, on the grid item wrapper, there it was.

product-grid.css
.product-grid__item {
  opacity: 0;
  transform: translateY(12px);
  will-change: opacity, transform;
}

.product-grid__item.is-revealed {
  opacity: 1;
  transform: none;
  transition: opacity 300ms, transform 300ms;
}

.product-card__sizes {
  position: absolute;
  z-index: 9999;
}
CSS

That’s a scroll reveal I built in 2022, a year after I’d been writing about IntersectionObserver and enjoying myself thoroughly. The cards fade and rise a few pixels as they come into view, staggered down the grid. It juddered on cheap Android phones without the will-change, so the will-change went in. It fixed that.

It also creates a stacking context, unconditionally, for as long as it’s in the stylesheet. The animation runs once, when the card scrolls past. The context stays for the life of the page, on every card in the grid, turning each one into its own little chapter.

And search results render the same card without that wrapper, because nobody staggers a results page. No wrapper, no will-change, no stacking context, dropdown on top. One template’s worth of difference, three levels above the component everybody agreed was broken.

Nobody types opacity while thinking about layering

The list of things that create a context is longer than anybody wants it to be, and almost none of it is about stacking.

position: relative with a z-index does it, and that one’s fair, you asked for layering and you got layering. The rest are properties somebody reached for to make something look a particular way. 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. contain. will-change, if you name any of those properties in it. 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 definitely forgetting a few. I’d go and read MDN’s list instead of mine.

Nobody types opacity: 0.99 while thinking about layering. That’s the whole problem. It’s a hover lift, or a fade-in, or a frosted blur on a header, and the consequence lands somewhere else entirely, usually in a component written by a different person in a totally different context. Many such cases!

And it fails quietly, which is what costs the hours. Nothing goes red, nothing shows up struck through in DevTools, because nothing is wrong with the rule you’re looking at. The panel just renders under the thing it’s meant to sit over, and everybody agrees the panel is broken.

I said this was over two weeks ago

I know the counter, because I made it two weeks ago. Render the thing at the end of <body>, or use a portal, or better, put it in the top layer with <dialog> and showModal(), or the popover attribute. Elements up there sit above the entire document regardless of stacking context, so the whole category of problem I’ve just spent 600 words on doesn’t exist for them.

That’s genuinely right, I use it, and if you’re building an overlay this year you should reach for it before you reach for a number.

I’d just say the modal is the loudest version of this bug and not the common one. The ones that actually reach me are local: a tooltip clipped by its scroll container, a sticky table column that won’t sit over the row underneath it, a focus ring eaten by the sibling next to it, a size dropdown inside a product card. None of those want to be above literally everything, and putting them there would be its own bug, since they’d stop moving with the thing they belong to. They want to be ordered correctly against three elements standing near them, which is exactly what stacking contexts govern. So you still have to know how this works.

One property and five numbers

Two things keep it from happening to me, and the first is isolation: isolate.

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

I’d also point out that it’s the property that would have caused this exact bug if I’d reached for it a year earlier, because stopping a component’s internals from leaking out is precisely the thing that went wrong here. So it belongs on boundaries where nothing is meant to escape, and a card whose dropdown has to overflow it isn’t one of those.

The second is having five layers and giving them names, in 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 tidiness isn’t the point. The point is that when somebody needs a layer that isn’t on the list, they have to come and talk to you about it, and that’s a five-minute conversation about what’s supposed to sit over what. It’s a much better conversation than the one where they type 10000 and move on and it becomes yours in a year.

Both of those were me

The will-change was mine. So was the 9999. I found it in the blame with my name against it and a commit message that says “fix dropdown stacking,” which it did not 😐

the fix
.product-grid__item {
  opacity: 0;
  transform: translateY(12px);
}

/* raise the card, not the panel inside it */
.product-grid__item:has(.product-card__sizes.is-open) {
  z-index: 1;
}
CSS

So most of the fix was deleting a line that had stopped doing anything useful the moment the animation finished, two years ago. The rest is lifting the card itself while its panel is open, with :has(), which as of December is finally in every engine. The card, not the panel inside it. You don’t promote the page, you move the chapter.

9999 came out, 1 went in, and 1 is the right number because it’s the only one anybody was ever reading. I’d like to say the lesson is architectural. But it’s smaller than that and more of a reflex. When a z-index doesn’t do what I expect now, I don’t try a bigger one. I go and click up the tree until a badge shows up, which has yet to take me more than about a minute, and the number has not once been the thing that was wrong.

Read similar posts
7 min

position: sticky and its containing block

Four declarations, all of them correct, and the one line stopping them from working was something I wrote three years ago on an element seven ancestors up.

2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.