z-index: 9999

No number on the dropdown was ever going to beat the stacking context a scroll reveal had quietly given every card in the grid.

#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.

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, and the dropdown sat 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

The whole trick fits in one sentence.

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 whole 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.

It finally stuck for me when I started 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, and the chapters order themselves. No page has ever been promoted into a different chapter by being large.

Which reframes the bug. When the number doesn’t work, that’s not 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 three components up, somewhere you had no reason to look, the same shape as the sidebar that wouldn’t stick that cost me an hour back in 2017.

Go and click up the tree

I should have run this search first. It takes a couple of minutes.

Neither Chrome nor Firefox will point at the element that made the context. There’s no badge for it, which I keep expecting and keep not finding. Edge has a 3D view that draws the stacking contexts for you. Everywhere else it’s clicking up the tree one ancestor at a time and reading the computed styles for anything on the list further down this post.

That still beats a 20-minute bisect. 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 stuttered 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 on every card in the grid for the life of the page. Each card becomes 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. 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 does it. So does transform, including the translateZ(0) somebody added in 2019 to force GPU acceleration. So do filter, backdrop-filter, mix-blend-mode, clip-path, mask and contain, plus will-change if you name any of those properties in it. position: fixed and position: sticky do it unconditionally, and grid and flex children do it 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. The consequence lands somewhere else entirely, usually in a component a different person wrote in a totally different context. Many such cases!

And it fails quietly, which is what costs the hours. Nothing goes red and 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.

This was supposed to be over

I know the counter because I’ve made it myself. 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 and I use it. 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. 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. 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 or leak out over things they shouldn’t. 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.

It would also 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. A card whose dropdown has to overflow it isn’t one of those.

The second is having five layers and naming them in one place, in order.

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. When somebody needs a layer that isn’t on the list, they have to come and have a five-minute conversation with you 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 on 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(). As of December that’s finally in every engine.

The card, not the panel inside it. You don’t promote the page, you move the chapter.

9999 came out and 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 one of those dozen properties turns up. It has yet to take me more than a few minutes, and the number hasn’t once been the thing that was wrong.

Read similar posts

View posts by tag

#accessibility #agency #ai #careers #cross-browser-compatibility #css #culture #debugging #documentation #ecommerce #forms #git #html #images #javascript #performance #php #privacy #scss #security #shopify #small-print #tooling #typography #wordpress