z-index Not Working: Finding the Ancestor That Traps It
A dropdown menu stopped working in exactly one place on our dashboard. The same component opens fine from the top bar, but the copy that lives in a card toolbar renders behind the sidebar. No console error, no layout shift. The menu just slides under an element that should be nowhere near it.
So I did what everyone does first. z-index: 9999. Then 99999. Then a number with seven digits, which is the moment you have to admit the number was never the problem. z-index was working fine. My number simply never had an effect, because nothing was comparing it.
A stacking context is local, not global
MDN states it plainly: elements within a stacking context are stacked independently from elements outside of it. A context is also atomic. Once the browser has ordered everything inside one, the whole thing takes part in its parent's order as a single unit.
That is the entire bug. My menu carried a huge z-index, but it sat inside .card. The sidebar is .card's sibling. The only comparison the browser made was .card against the sidebar, and the menu's number never got a vote.
The contexts I keep forgetting I created
Some triggers are common knowledge. An element with position: absolute or relative and a z-index other than auto. An element with position: fixed or sticky, which creates a context even with no z-index at all. A sticky header makes a stacking context just by existing.
The rest of the list is where it stops being intuitive, because most of it has nothing to do with layering. opacity below 1. Any of transform, scale, rotate, translate, filter, backdrop-filter, perspective, clip-path or mask set to something other than none. mix-blend-mode other than normal. contain: layout or contain: paint. isolation: isolate. A will-change naming one of those properties, which is how will-change: transform added for animation performance ends up trapping every z-index inside a component. And an element faded in through @keyframes with animation-fill-mode: forwards.
Two more, and both of them were mine. Declaring container-type: size or container-type: inline-size creates a stacking context permanently, with no z-index anywhere in sight. I gave that card container-type: inline-size in July so it could respond to its own width, and it had been fencing off every z-index inside it ever since. content-visibility: auto turns on paint containment, which lands you in the same pile, and it usually gets added as a rendering optimization on lists of cards.
The container query guide I wrote in July is still correct about sizing. It just never mentioned this part.
Find the ancestor instead of guessing numbers
Every one of those triggers is readable from getComputedStyle, so you can skip the devtools treasure hunt. I keep this in a snippet and run it the moment an overlay misbehaves.
const VISUAL = ['transform', 'scale', 'rotate', 'filter', 'backdrop-filter',
'perspective', 'clip-path', 'mix-blend-mode']
function contextReason(el) {
const s = getComputedStyle(el)
if (el === document.documentElement) return 'root element'
if (s.position === 'fixed' || s.position === 'sticky') return 'position: ' + s.position
const positioned = s.position === 'absolute' || s.position === 'relative'
const parentDisplay = getComputedStyle(el.parentElement).display
const isItem = parentDisplay.includes('flex') || parentDisplay.includes('grid')
if (s.zIndex !== 'auto' && (positioned || isItem)) return 'z-index: ' + s.zIndex
if (parseFloat(s.opacity) < 1) return 'opacity: ' + s.opacity
if (s.isolation === 'isolate') return 'isolation: isolate'
if (s.containerType !== 'normal') return 'container-type: ' + s.containerType
if (/layout|paint|strict|content/.test(s.contain)) return 'contain: ' + s.contain
if (s.willChange !== 'auto' && !/scroll-position|contents/.test(s.willChange)) {
return 'will-change: ' + s.willChange
}
for (const prop of VISUAL) {
const v = s.getPropertyValue(prop)
if (v !== 'none' && v !== 'normal') return prop + ': ' + v
}
return null
}
function fences(el) {
const hits = []
for (let n = el; n; n = n.parentElement) {
const reason = contextReason(n)
if (reason) hits.push([n, reason])
}
return hits
}
fences(document.querySelector('.toolbar-menu'))
.forEach(([el, reason]) => console.log(el, reason))
Start at the last line of that output and walk up. The first entry with a z-index of its own is the decisive one, because that is the number the browser actually compared. My card was a plain static block, so z-index: 3 on it did nothing until I gave it position: relative. On a static element, z-index is a property the browser ignores.
The bug that looks the same and is not
A few weeks earlier, a different overlay had the same search phrase and a different cause. Nothing was covering it. A panel with position: fixed had simply moved: it scrolled away with the page and got clipped by the card it had started inside.
The mechanism there is the containing block. If an ancestor between a fixed element and the root has a transform, filter, backdrop-filter, perspective, rotate, scale or translate value other than none, or a contain of layout, paint, strict or content, or content-visibility: auto, or a will-change naming any of those, that ancestor becomes the containing block. Fixed stops meaning the viewport and starts meaning that ancestor, and the ancestor's overflow clips it from then on. MDN flags browser inconsistencies around perspective and filter here, which is reason enough not to lean on them.
No z-index fixes that one. The element is not losing a paint comparison, it is being laid out against the wrong box.
Fix it at the level that pays
Moving the overlay out of the trap is the only fix that keeps working as an app grows. If it is a modal or a popover, showModal() or showPopover() puts it in the top layer, which the browser paints above every stacking context on the page. The catch is that you cannot opt in: only fullscreen elements, modal dialogs, popovers and an open select picker ever land there, and no amount of CSS promotes a plain div into it. That is why wrapping a tooltip in a popover turned into structural work rather than a styling tweak.
Raising the ancestor works too, and for one card it is the cheapest fix. Do it five times and you have the pile of z-index values nobody can reason about later. When I do want a local scale, I build it on purpose. isolation: isolate hands a component its own context, so the numbers inside stay small.
:root {
--z-dropdown: 10;
--z-sticky: 20;
--z-drawer: 30;
--z-modal: 40;
--z-toast: 50;
}
.toast-stack {
position: fixed;
inset-block-start: 1rem;
inset-inline-end: 1rem;
z-index: var(--z-toast);
}
.card {
/* a local order for this component, on purpose */
isolation: isolate;
}
None of this has anything to do with cascade layers. Those decide which declaration wins a conflict, stacking contexts decide what paints on top. Two unrelated systems, and I lost most of an afternoon to that confusion once.
What no number will fix
Two places no z-index reaches: shadow DOM and iframes. The host element of a shadow tree is the only part that takes part in the parent's stacking order, so nothing inside a web component climbs out of it. An iframe's contents cannot paint above the page hosting them either.
The walker above is the one thing I kept from that afternoon. It sits in my snippet collection next to the z-index scale, and it runs before I touch a number now.