Skip to content
sang.id.vn
Go back

When CSS z-index Gets Ignored

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:

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:

8. A Debugging Checklist for Stubborn z-index

When I hit this kind of bug, I usually go in this order:

  1. Does the element actually accept z-index? Does it have position, or is it a valid flex/grid item?
  2. Which stacking context is it inside? Which ancestor has transform, opacity, filter, contain, isolation, or will-change?
  3. Is any ancestor clipping it with overflow, clip-path, mask, or contain: paint?
  4. If it uses position: fixed, is an ancestor turning it into fixed relative to a container?
  5. Is anything in the top layer, such as <dialog>, popover, or fullscreen?
  6. When z-index is equal or auto, is DOM order deciding the paint order?
  7. 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.


Share this post on:

Next Post
Big Bang vs. Sharing Economy — A Surprisingly Perfect Analogy