A modal has z-index: 9999. The site header has z-index: 10. The modal appears under the header. Raising the modal to 99999 changes nothing. This is the classic stacking context bug, and the reason it confuses people is that z-index looks like a global layer number when it isn’t. It’s a position within a group, and the modal and the header aren’t in the same group.
z-index is local to a stacking context
A stacking context is a group of elements that are layered together as one unit. The root element (<html>) forms the first one. Some elements form new ones inside it. Within a stacking context, children are ordered by their z-index. But the whole context is then placed into its parent context as a single layer, using the z-index of the element that created it.
So the modal’s 9999 is compared only with its siblings inside the same context. If the modal’s context is itself at layer 1 in the root context, and the header is at layer 10 in the root context, the header wins, regardless of what’s inside layer 1.
Think of it as version numbers: the modal is 1.9999 and the header is 10. No amount of nines after the decimal point beats 10.
The bug, reproduced
<header class="site-header">Stacktrick</header>
<main class="page">
<div class="modal-backdrop">
<div class="modal">Are you sure?</div>
</div>
</main>.site-header {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
}
.page {
position: relative;
z-index: 1;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
background: rgb(0 0 0 / 0.5);
}What renders: the dimmed backdrop covers the page content but the header stays bright and clickable on top of it. The modal’s top edge is hidden behind the header if they overlap.
Trace it:
- The root stacking context contains two relevant children:
.site-header(positioned,z-index: 10, so it creates a context at layer 10) and.page(positioned,z-index: 1, creates a context at layer 1). .modal-backdropis inside.page, so itsz-index: 9999positions it within.page’s context. It’s above everything else in.page, which is why it covers the page content.- In the root context, layer 10 paints after layer 1, so the header (and everything in it) paints over the whole of
.page, backdrop included.
.page { position: relative; z-index: 1; } might have been added years ago to fix an unrelated overlap with a decorative background. That’s typical: the context that causes the bug is rarely in the component you’re looking at.
What creates a stacking context
The list is longer than most people expect, which is why contexts appear by accident. The common ones:
- The root element.
position: relativeorabsolutewith az-indexother thanauto.position: fixedorsticky, always (even without a z-index).- A flex or grid item with a
z-indexother thanauto, even withoutposition. opacityless than 1.transform,filter,backdrop-filter,perspective,clip-pathormaskwith any value other thannone.mix-blend-modeother thannormal.isolation: isolate.will-changenaming any of the properties above.contain: layoutorpaint, andcontainer-typeofsizeorinline-size.
The surprising entries are opacity and transform. A fade-in animation that leaves opacity: 0.99, or a hover effect with transform: translateY(-2px), creates a context, and any dropdown inside that card can no longer layer above the next card. A dropdown that disappears behind its neighbour only on hover is almost always this.
Painting order inside one context
Within a single stacking context, the browser paints in this order, back to front:
- The context element’s own background and borders.
- Children with negative
z-index, lowest first. - In-flow, non-positioned block boxes, in DOM order.
- Non-positioned floats.
- In-flow inline content (text, inline images, inline-block boxes).
- Positioned children with
z-index: autoor0, in DOM order. - Positioned children with positive
z-index, lowest first.
Two consequences come up in interviews. First, a positioned element with no z-index still paints above non-positioned siblings, even ones later in the DOM, because step 6 comes after steps 3 to 5. Second, z-index on a non-positioned element does nothing at all (except on flex and grid items), which is why adding z-index: 100 to a static element “doesn’t work”.
Negative z-index has its own trap: a child with z-index: -1 goes behind its parent’s content but stays in front of the parent’s background only if the parent forms a stacking context. If the parent doesn’t, the child drops behind the parent’s background entirely, often behind the page itself.
Fixing it properly
Raising numbers never fixes a context problem. The options, roughly in order of preference:
Render overlays at the top of the tree. Modals, toasts and popovers belong as direct children of <body> (via React’s createPortal, Angular CDK’s overlay or Vue’s Teleport), so they live in the root context and compete directly with the header.
Use the top layer. <dialog> opened with showModal() and elements with the popover attribute render in the browser’s top layer, which sits above every stacking context, whatever the z-index values. Its ::backdrop pseudo-element handles the dimming. For new code this is the cleanest fix:
<dialog id="confirm">
<p>Are you sure?</p>
<form method="dialog"><button>Close</button></form>
</dialog>#confirm::backdrop {
background: rgb(0 0 0 / 0.5);
}Calling document.getElementById('confirm').showModal() renders it above the sticky header, makes the rest of the page inert, and traps focus inside the dialog.
Remove the accidental context. If .page’s z-index: 1 isn’t needed, deleting it means .page no longer forms a context, and the backdrop competes in the root context with its 9999.
Contain components on purpose. isolation: isolate creates a context without needing position or a z-index. Putting it on each card makes the card’s internal z-indexes (a badge at 2, an overlay at 1) local, so they can’t leak out and cover the header. That’s the deliberate use of the same mechanism that causes the bug.
A small, named scale helps teams avoid escalation: custom properties like --z-dropdown: 100; --z-sticky: 200; --z-modal: 300; --z-toast: 400;, used everywhere instead of literal numbers.
Debugging
In Chrome DevTools, the Layers panel (More tools, then Layers) shows composited layers in 3D, and Edge’s 3D View has a z-index mode that shows stacking contexts directly. The quicker manual approach: select the element that’s wrongly underneath, then walk up its ancestors in the Elements panel and check each one’s computed styles for the properties listed above. The first ancestor that creates a context, and whose z-index is lower than the thing on top, is your culprit.
Interview practice
A dropdown menu inside a card has position: absolute; z-index: 50. The cards are in a grid, and each card has position: relative (so the menu is placed against it), transition: transform 200ms, and transform: translateY(-4px) on hover. When the menu is open and the user hovers the card, the menu disappears behind the card below. Why, and how do you fix it?
Answer
On hover, the card gets a transform, which creates a new stacking context. The menu’s z-index: 50 now ranks it only inside its own card. The cards are all positioned with z-index: auto, so they share one painting step in the parent context and paint in DOM order; the hovered card’s new context is painted in that same step. The next card comes later in the DOM, so it paints over the hovered card as a whole, menu included. Before hover, the card didn’t form a context, so the menu’s 50 competed directly with the other cards and won.
Fixes, from smallest to most robust: give the hovered or open card a higher z-index (.card:hover, .card.menu-open { z-index: 1; }, which works on grid items without position), so its whole context paints above its siblings. Or render the menu outside the card, in a portal or with the popover attribute, so the card’s context can’t trap it. The popover approach also avoids clipping if the grid later gets overflow: hidden. Raising the menu’s z-index to 9999 would change nothing, which is a useful thing to say out loud in the interview.
Keep going
- HTML and CSS topic hub
- HTML and CSS multiple-choice questions
- CSS positioning and containing blocks
- CSS transforms and animations
- MDN: stacking context