A tooltip opens under the header. You give it z-index: 10, then 100, then 9999. Nothing changes.
The number was never the problem. The tooltip is sealed inside something it can't get out of. Try it below.
z-index only competes with its siblings
The browser doesn't paint the page as one pile of boxes sorted by z-index. It paints a tree of groups called stacking contexts.
A stacking context is a sealed layer. Everything inside it is sorted among itself, then the whole layer is painted as one flat sheet into its parent. In MDN's words, each one is treated "atomically as a single unit in the parent stacking context".
So a z-index is a rank within one group, not a position on the page. Your tooltip's 9999 beats everything else inside the card. Then the card, as a single sheet, is compared with the header, and the card's own rank is what counts.
Ordinary CSS creates them
If only z-index made stacking contexts, this would be easy to track. But some effects only make sense on a group. opacity: 0.5 fades the card and its children as one image, so they have to be flattened together first, and nothing from outside can be slotted in between. Any property like that makes one. From MDN's full list:
position: relativeorabsolutewith az-indexother thanauto.position: fixedorsticky, always, with no z-index needed.- A flex or grid item with a
z-index, even withoutposition. opacitybelow 1. Even 0.99.transform,scale,rotate,translate,filter,backdrop-filter,perspective,clip-pathormaskwith any value butnone.mix-blend-modeother thannormal.isolation: isolate.will-changenaming any of those properties, even before it changes.contain: layoutorpaint, andcontainer-type: sizeorinline-size.
That's why this bug arrives without anyone touching z-index. Someone adds a hover lift with transform, or a fade-in that leaves opacity at 0.99, or makes a card a size container for a container query. Each one quietly seals everything inside it.
The order inside one
Within a single stacking context, the browser paints in a fixed order. It's in CSS 2's Appendix E, and simplified, it goes like this:
- Background of the context itself
- Negative z most negative first
- Blocks in-flow, not positioned
- Floats
- Inline content text, images
- z auto and 0 positioned, in HTML order
- Positive z lowest first
A few things fall out of that list. Ties are settled by HTML order, so a later element with the same z-index wins. A positioned element with no z-index still paints above every non-positioned block, which is why adding position: relative alone can change what's on top.
And a negative z-index can slide under its parent's content but never under its parent's background, if that parent is a stacking context. Try -1 in the demo with the card sealed, then unsealed.
Clipping is a different problem
Even when z-index works, a tooltip inside a card with overflow: hidden gets cut off at the card's edge. That's not stacking at all. It's clipping, and z-index has no say in it.
The rule is about containing blocks. Overflow clips every descendant except those whose containing block is outside the clipping element. So an absolutely positioned tooltip escapes if its nearest positioned ancestor is above the card, and position: fixed escapes because its containing block is the viewport.
Except transform breaks that too. A transformed element becomes the containing block for its fixed descendants, so position: fixed inside it behaves like absolute, and gets clipped. filter, perspective, contain: paint and a few others do the same thing.
So one transform on a card can do two things at once. It seals the stacking order, and it takes away the usual escape route from clipping.
The top layer
For years, the fix was a portal: render the tooltip as a child of <body> instead of inside the card, then position it by hand with coordinates from getBoundingClientRect(). React has createPortal for exactly this. It works, but now your tooltip lives far from its trigger in the DOM, and focus order and event bubbling need care.
The platform has a real answer now. The top layer is a layer above the root stacking context, outside the normal tree. Per the CSS Positioned Layout spec, its elements are laid out "as if they were siblings of the root element", and ancestors "with overflow, opacity, mask, etc. cannot affect it". Your element stays where it is in the HTML. Only how it's painted moves.
Three things put an element there: <dialog> opened with showModal(), an element with the popover attribute when it's shown, and fullscreen. The popover attribute has worked in every major browser since 2024 (Chrome 114, Safari 17, Firefox 125).
<button popovertarget="menu">Account</button>
<div id="menu" popover>Signed in as you</div>
Inside the top layer there's no z-index either. Order is just order of opening: the last one shown is on top. So a menu opened from inside a modal dialog lands above the dialog, which is exactly what you want.
Each top-layer element also gets a ::backdrop, a full-viewport box painted directly beneath it. It's what dims the page behind a modal:
dialog::backdrop {
background: rgb(0 0 0 / 0.4);
}
The top layer doesn't position anything for you. A popover is position: fixed and centred by default, so a tooltip still needs to be placed next to its trigger, with JavaScript or with CSS anchor positioning. But the stacking problem is gone for good.
What I'd do instead of 9999
For anything that floats above the page, like menus, tooltips, dialogs and toasts, I'd use the top layer and stop thinking about z-index entirely.
For components, I'd put isolation: isolate on the root of each one. It creates a stacking context and does nothing else, with no visual change and no new containing block. Whatever z-indexes the component uses inside stay inside, and can't fight with the rest of the page.
Then the page-level z-indexes you have left are few enough to name. When a number stops working, the question to ask isn't "how high?" It's "which sheet is this on?"