You set z-index: 999999. The tooltip still sits behind the header. The modal is still covered by a card. The dropdown is not covered, but half of it gets clipped.
This is one of those CSS bugs that makes the first instinct feel very reasonable: make the number bigger. 999 does not work. 9999 still does not work. 2147483647 sounds determined, but the layout stays exactly as broken as before.
The problem is that z-index is not a global leaderboard for the whole page. It only matters under certain conditions, and sometimes the browser has other mechanisms that take priority.
1. z-index Does Nothing Unless the Element Can Use It
The simplest case: you assign z-index, but the element is not in a state where z-index applies.
<div class="box-a">A</div>
<div class="box-b">B</div>
.box-a {
z-index: 10;
}
.box-b {
margin-top: -40px;
}
In many layouts, z-index on .box-a will not help because the element is still using the default position: static. The minimal fix:
.box-a {
position: relative;
z-index: 10;
}
Flex and Grid items can also accept z-index without position, but when debugging a normal layout, check position first. It is a cheap trap, but it wastes a surprising amount of time.
2. A Stacking Context Is an Invisible Box Around z-index
The most important idea: z-index is compared only inside the same stacking context. If an element is trapped inside a lower stacking context, its children cannot escape just by using a huge z-index.
Example:
<div class="panel">
<div class="tooltip">Tooltip</div>
</div>
<div class="header">Header</div>
.panel {
position: relative;
z-index: 1;
}
.tooltip {
position: absolute;
z-index: 9999;
}
.header {
position: relative;
z-index: 2;
}
At a glance, .tooltip has z-index: 9999, while .header only has z-index: 2. But .tooltip lives inside .panel, and .panel is at level 1. The browser compares .panel against .header first. Since 1 < 2, the entire .panel, including the tooltip inside it, remains below the header.
In other words, a child of house number 1 cannot move into house number 2 just because it writes 9999 on its shirt.
Common properties that create a new stacking context include:
positionwith az-indexother thanautoposition: fixedorposition: stickyopacityless than1transform,scale,rotate,translatefilter,backdrop-filter,perspectiveisolation: isolatecontain: layout,contain: paint,contain: strict,contain: contentwill-changepointing to a property that can create a stacking contextmix-blend-modeother thannormalcontainer-type: sizeorinline-size
You do not need to memorize the whole list. When an overlay refuses to rise, walk up its ancestors and look for properties like these. The culprit is often an innocent-looking wrapper:
.page {
transform: translateZ(0);
}
That line is sometimes added to “optimize animation”, but it also creates a new stacking context. From there, a tooltip can begin its new life as a trapped element.
3. overflow Does Not Care How High Your z-index Is
Another common misread: the element is not being covered. It is being clipped.
<div class="card">
<button>Open menu</button>
<div class="menu">Menu content</div>
</div>
.card {
position: relative;
overflow: hidden;
}
.menu {
position: absolute;
top: 100%;
z-index: 9999;
}
The menu may really be on a higher layer, but .card says anything outside its bounds should be cut off. z-index is not scissors-proof. It cannot make an element escape ancestor clipping.
Properties worth checking:
overflow: hidden;
overflow: clip;
overflow: auto;
clip-path: inset(0);
mask-image: linear-gradient(#000, transparent);
contain: paint;
For dropdowns, tooltips, popovers, and context menus, the usual fix is to render them closer to body instead of leaving them deep inside a card with overflow: hidden. In React/Vue, this is one reason portals/teleports are so common for overlays.
4. position: fixed Is Not Always Fixed to the Viewport
Many people think position: fixed always sticks to the viewport. Usually, yes. But if an ancestor has transform, filter, perspective, or some forms of contain, a fixed element can become fixed relative to that ancestor instead.
<div class="app-shell">
<div class="modal">Modal</div>
</div>
.app-shell {
transform: translateZ(0);
}
.modal {
position: fixed;
inset: 0;
z-index: 9999;
}
The modal looks like it should cover the whole viewport, but the browser may treat .app-shell as the containing block for the fixed element. If .app-shell is smaller than the viewport, scrolls, or lives in a lower stacking context, the modal is pulled into that mess too.
A quick debug move is to temporarily disable suspicious ancestor properties:
.app-shell {
/* transform: translateZ(0); */
}
If the modal suddenly behaves correctly, you found the boundary trapping it. A cleaner fix is usually to move the modal outside .app-shell, render it near body, or remove the unnecessary transform.
5. The Top Layer Beats z-index
HTML has a concept called the top layer. Things like <dialog> opened with showModal(), the Popover API, and fullscreen elements can be placed by the browser into a special layer above normal layout.
<div class="custom-modal">Custom modal</div>
<dialog id="native-dialog">Native dialog</dialog>
.custom-modal {
position: fixed;
inset: 0;
z-index: 999999;
}
document.getElementById("native-dialog").showModal();
In this situation, your custom modal can still appear under the native dialog, even with a massive z-index. That is not because your CSS is broken. The top layer is a separate browser mechanism designed to give certain UI states, like modal dialogs, popovers, and fullscreen, the correct priority.
So if an app mixes custom overlays with native <dialog> or popover elements, do not only inspect z-index. Check whether something is currently in the top layer.
6. DOM Order Still Matters When z-index Is Equal
When elements are in the same stacking context and their z-index values are equivalent, DOM order can decide which one appears on top.
<div class="box box-a">A</div>
<div class="box box-b">B</div>
.box {
position: relative;
z-index: auto;
}
.box-a {
margin-bottom: -40px;
}
If two elements overlap and neither has an explicit layer, the one that appears later is usually painted later, so it looks like it is on top. This is why changing component render order can sometimes make a layer bug disappear even when the CSS did not change.
But do not rely on DOM order as your layer system. For overlay-heavy UI, define a small and consistent layer scale:
:root {
--z-header: 100;
--z-dropdown: 200;
--z-popover: 300;
--z-modal: 400;
--z-toast: 500;
}
The numbers do not need to be huge. The hierarchy needs to be clear.
7. Sometimes It Is Not a z-index Bug, but an Interaction Bug
An element can be visibly on top but still not receive clicks. Or clicking an overlay can affect the element behind it. It is easy to blame z-index, but the real cause may be pointer-events, focus, event propagation, or JavaScript.
Example:
.overlay {
position: fixed;
inset: 0;
z-index: 400;
pointer-events: none;
}
The overlay is really on top, but pointer-events: none tells the browser to ignore it during hit testing. Clicks pass through to whatever is underneath.
Or the opposite:
.backdrop {
position: fixed;
inset: 0;
z-index: 300;
}
.modal {
position: fixed;
z-index: 400;
}
If JavaScript attaches the close handler to the backdrop but the modal is rendered inside that backdrop, clicks inside the modal may bubble to the backdrop and close it. From the outside, it feels like a layer problem. In reality, the event flow is wrong.
Quick checks:
- Use DevTools to inspect the exact element under the cursor.
- Check the computed
pointer-eventsvalue. - Temporarily add a translucent background so you can see the real box.
- Log
event.targetandevent.currentTarget. - Check whether the overlay is rendered into the wrong container through a portal/teleport.
8. A Debugging Checklist for Stubborn z-index
When I hit this kind of bug, I usually go in this order:
- Does the element actually accept
z-index? Does it haveposition, or is it a valid flex/grid item? - Which stacking context is it inside? Which ancestor has
transform,opacity,filter,contain,isolation, orwill-change? - Is any ancestor clipping it with
overflow,clip-path,mask, orcontain: paint? - If it uses
position: fixed, is an ancestor turning it into fixed relative to a container? - Is anything in the top layer, such as
<dialog>, popover, or fullscreen? - When
z-indexis equal orauto, is DOM order deciding the paint order? - If it looks right but clicks wrong, check
pointer-events, focus traps, event propagation, and portals.
The main takeaway: do not keep increasing z-index like repeatedly pressing an elevator button. If the number is already high and nothing changes, the problem is almost certainly not the number anymore. Look for the box that traps it, the clipping rule that cuts it, or the special layer that sits outside the normal game.
Once you understand those boundaries, z-index feels a lot less like dark magic. It is still annoying, but at least you know who to argue with.