A z-index bug often looks irrational. You give a menu z-index: 9999, yet a header still covers it. Increasing the number to 999999 changes nothing. The natural conclusion is that z-index is unreliable.

It is not. The mistake is usually comparing numbers that belong to different stacking contexts.

A stacking context is a local coordinate system for painting overlapping elements. Descendants are ordered inside that context, and then the entire context participates as one unit in its parent’s stacking order. A child with a huge z-index cannot escape a parent that is painted below a sibling context.

That mental model is more useful than memorizing arbitrary z-index values.

Understand the local nature of z-index

Consider this page:

<header class="site-header">Header</header>

<main class="content">
  <div class="modal">Modal</div>
</main>
.site-header {
  position: relative;
  z-index: 2;
}

.content {
  position: relative;
  z-index: 1;
}

.modal {
  position: fixed;
  z-index: 9999;
}

You might expect the modal to appear above the header because 9999 is larger than 2.

It does not.

Both .site-header and .content create stacking contexts because they are positioned elements with a z-index other than auto. In the root stacking context, the browser compares:

.site-header -> z-index 2
.content     -> z-index 1

The entire .content context is below .site-header. The modal’s z-index: 9999 is only meaningful inside .content.

You can think of the result as a hierarchy:

root
├── .content      (1)
│   └── .modal    (9999)
└── .site-header  (2)

The browser does not flatten those numbers into one global list. It resolves the modal inside .content, then treats .content as one unit when comparing it with .site-header.

Fix the parent relationship, not the child number

When a descendant cannot rise above another element, increasing the descendant’s z-index is often the wrong fix.

One option is to raise the ancestor stacking context:

.content {
  position: relative;
  z-index: 3;
}

Now .content is above .site-header in the root context.

That is correct only if the whole content region should actually sit above the header. Often it should not.

For overlays such as dialogs, popovers, and global menus, a better architectural fix is usually to place the overlay outside the constrained ancestor:

<header class="site-header">Header</header>
<main class="content">Page content</main>

<div class="overlay-root">
  <div class="modal">Modal</div>
</div>
.overlay-root {
  position: fixed;
  inset: 0;
  z-index: 100;
}

Now the overlay participates in a higher-level stacking context instead of trying to escape from a lower one.

This is the same reason many component systems render global overlays near the document root.

Know what creates a stacking context

Positioning plus z-index is only one way to create a stacking context.

Common triggers include:

  • the document root;
  • position: relative or position: absolute with z-index other than auto;
  • position: fixed or position: sticky;
  • flex or grid items with z-index other than auto;
  • opacity less than 1;
  • transform other than none;
  • filter or backdrop-filter other than none;
  • isolation: isolate;
  • contain: layout, contain: paint, or composite containment values that include them;
  • some will-change values that anticipate a property that would create a stacking context;
  • elements placed in the browser’s top layer, such as certain dialogs and popovers.

The surprising entries are usually visual-effect properties such as transform and opacity.

This harmless-looking animation setup creates a new stacking context:

.card {
  transform: translateZ(0);
}

So does:

.panel {
  opacity: 0.99;
}

If a tooltip inside one of those elements suddenly appears underneath a sibling, the problem may have nothing to do with the tooltip’s own styles.

Use a minimal example to see the boundary

Here is a small example where a child cannot outrank a sibling context:

<div class="left">
  <div class="badge">Badge</div>
</div>

<div class="right">Right panel</div>
.left {
  position: relative;
  z-index: 1;
}

.badge {
  position: absolute;
  z-index: 1000;
}

.right {
  position: relative;
  z-index: 2;
}

The badge is above other content inside .left, but .right is still above the entire .left context.

Change .badge from 1000 to 1000000 and nothing important changes.

Change .left from 1 to 3, and the relationship changes immediately because you modified the level that is actually compared with .right.

That gives you a practical debugging question:

Which ancestor stacking contexts are being compared at the point where the overlap is wrong?

That question is usually more productive than asking which element has the largest z-index.

Separate stacking context from containing block

Stacking and positioning are related, but they are not the same mechanism.

A stacking context controls painting order.

A containing block establishes the coordinate system used to resolve positions and sizes for descendants.

Some CSS properties affect both, which is why the concepts are easy to mix up.

For example, a non-none transform creates a stacking context. It also establishes a containing block for positioned descendants, including position: fixed descendants.

That can produce two symptoms at once:

.app-shell {
  transform: translateX(0);
}

.toast {
  position: fixed;
  inset-block-start: 1rem;
  inset-inline-end: 1rem;
}

A developer may expect .toast to stay fixed to the viewport. Because the transformed ancestor establishes its containing block, the toast can instead behave relative to that ancestor.

If the toast also loses a layering battle, both issues may trace back to the same transform, but for different CSS reasons.

Debug them separately:

wrong coordinates -> inspect containing blocks
wrong overlap     -> inspect stacking contexts

Do not confuse overflow clipping with z-index

Sometimes an overlay is not hidden behind another element. It is being clipped by an ancestor.

For example:

.card {
  overflow: hidden;
  border-radius: 1rem;
}

.tooltip {
  position: absolute;
  z-index: 9999;
}

If the tooltip extends outside .card, z-index cannot make the clipped pixels visible.

The problem is clipping, not stacking order.

A useful diagnostic is to distinguish these cases:

covered by another element -> stacking problem
cut off at ancestor bounds -> clipping problem

Possible fixes differ. You might move the tooltip outside the clipping container, change the overflow strategy, or redesign the boundary. Raising z-index will not solve clipping.

Negative z-index has boundaries too

Negative z-index values can place positioned content behind other content in the same stacking context:

.decoration {
  position: absolute;
  z-index: -1;
}

But negative values do not mean “behind the entire page.”

Once an element is inside a stacking context, its negative stack level is still resolved inside that context. The parent context itself remains an atomic participant in its parent’s stacking order.

This matters for decorative pseudo-elements:

.card {
  position: relative;
  isolation: isolate;
}

.card::before {
  content: "";
  position: absolute;
  inset: -0.5rem;
  z-index: -1;
}

isolation: isolate deliberately creates a stacking context. That can be useful because the negative pseudo-element stays behind the card’s content without unexpectedly falling behind unrelated ancestors.

Here, creating a stacking context is a feature rather than a bug.

Use isolation when you want a deliberate boundary

Accidental stacking contexts make debugging harder. Deliberate stacking contexts can make components easier to reason about.

Suppose a component has internal layers:

<article class="profile-card">
  <img class="profile-card__background" src="cover.jpg" alt="">
  <div class="profile-card__content">...</div>
</article>
.profile-card {
  position: relative;
  isolation: isolate;
}

.profile-card__background {
  position: absolute;
  inset: 0;
  z-index: -1;
}

.profile-card__content {
  position: relative;
  z-index: 1;
}

isolation: isolate says that the component should establish its own stacking context even without another trigger.

That is useful when a component needs predictable internal layering but should not leak those layer relationships into surrounding content.

The trade-off is real: descendants also cannot escape the isolated context. Do not add isolation globally or mechanically.

Prefer a small layer scale over arbitrary escalation

Once stacking contexts are correct, a small z-index scale is easier to maintain than random large values.

For example:

:root {
  --z-base: 0;
  --z-dropdown: 10;
  --z-sticky: 20;
  --z-overlay: 30;
  --z-modal: 40;
}

Then:

.site-header {
  z-index: var(--z-sticky);
}

.dropdown {
  z-index: var(--z-dropdown);
}

.modal {
  z-index: var(--z-modal);
}

The exact numbers do not matter. The relationships do.

A scale helps document intended priority, but it cannot cross stacking-context boundaries. --z-modal: 40 still loses if the modal lives inside a parent context that is below a sibling.

So treat a layer scale as an organizational tool, not a substitute for understanding the context tree.

Inspect the context tree systematically

When an overlap is wrong, debug from the two competing elements upward.

1. Identify the elements that visually conflict

Do not begin by searching the whole stylesheet for z-index.

Pick the two elements involved. For example:

dropdown
header

2. Walk up their ancestors

For each element, look for ancestors that create stacking contexts.

Pay special attention to:

position
z-index
opacity
transform
filter
isolation
contain
will-change

Browser developer tools can help you inspect computed styles and, in some browsers, visualize stacking information.

3. Find the first contexts that are siblings

Suppose the hierarchy is:

root
├── header-context
└── page-context
    └── card-context
        └── dropdown

The important first comparison is not dropdown versus header.

It is:

page-context versus header-context

If page-context is below header-context, no child value inside the page can fix that relationship.

4. Decide whether the context is intentional

If a stacking context comes from an unnecessary property, remove the trigger.

For example, if this transform exists only as a historical workaround:

.page {
  transform: translateZ(0);
}

removing it may be the simplest correct fix.

Do not remove transform, opacity, containment, or isolation blindly. They may serve visual, layout, performance, or component-boundary purposes.

5. Move global overlays when necessary

If a component genuinely needs its local stacking context but a descendant must appear above the entire application, move that overlay to a higher-level container.

This separates two requirements:

component internals -> local stacking context
global overlay      -> application-level overlay context

That is usually clearer than weakening a useful component boundary.

The browser top layer is different

Some browser-managed UI participates in the top layer rather than ordinary document stacking.

A modal <dialog> shown with showModal(), for example, is placed in the top layer. Popovers can also enter the top layer.

Top-layer elements are rendered above ordinary document stacking contexts, so they avoid many z-index battles.

That does not mean every overlay should automatically become a dialog or popover. Those elements have their own semantics, interaction behavior, focus expectations, and API contracts.

Use them when the UI matches those semantics, not merely as a trick to win a stacking fight.

Common mistakes

Increasing z-index until the bug disappears

This works only when the competing elements are in a context where their stack levels are actually comparable.

If they are separated by ancestor contexts, larger numbers add noise without changing the outcome.

Looking only at positioned ancestors

transform, opacity, filter, containment, and isolation can create boundaries without an obvious z-index.

Inspect all common stacking-context triggers.

Removing a context without understanding why it exists

A property may be there for animation, clipping architecture, containment, or intentional isolation.

Fix the layering issue without silently breaking another invariant.

Treating every overlay as local component content

Dropdowns and tooltips sometimes belong inside their component. Modals and application-wide notifications often need a higher-level overlay root.

Choose the DOM location based on the overlay’s visual scope.

Assuming z-index fixes clipping

Stacking determines which box paints over another. It does not cancel an ancestor’s clipping behavior.

When to use z-index and when not to

Use z-index when two boxes overlap and you need to control their order inside a meaningful stacking relationship.

Do not reach for it when the real problem is:

  • clipping from overflow;
  • unexpected positioning caused by a containing block;
  • a global overlay trapped inside a local component boundary;
  • DOM architecture that puts unrelated layers in the same place;
  • a browser top-layer primitive that better matches the UI.

The simplest correct fix is often structural rather than numerical.

Build the right mental model

z-index is not a global priority number. It is a stack level interpreted within a hierarchy of stacking contexts.

When a huge z-index loses, compare the ancestors before changing the number. Find the first sibling stacking contexts, determine which one is above the other, and decide whether that boundary is intentional.

Once you debug the context tree instead of escalating numbers, overlapping UI becomes far more predictable.