Skip to content
DevDepth

In-depth React internals and CSS layout guides for frontend engineers.

← Back to all articles

In-Depth Article

Why `z-index` Is Not Working and How to Fix It

Learn why `z-index` fails on static elements, why stacking contexts trap child layers, and how to fix overlap bugs in positioned, Flexbox, and Grid layouts.

Published: Updated: 7 min readlayout-strategy
z-indexstacking-contextcss-positionlayout-strategy

z-index feels simple until it fails in a real interface.

You set z-index: 999, reload the page, and the element still sits underneath something else. Or a tooltip refuses to rise above a header. Or a pseudo-element with z-index: -1 disappears in a way that makes no sense at first glance.

Most of those bugs come from the same misunderstanding:

z-index does not create a global ranking for the whole page. It only compares layers inside the stacking context that the element belongs to.

Once that rule clicks, most "random" overlap bugs stop feeling random.

This guide focuses on the failure modes developers hit most often:

  • z-index applied to an element that cannot use it yet
  • a parent stacking context trapping a child below another branch of the page
  • accidental stacking contexts created by other CSS properties
  • negative z-index behaving differently than expected

If you want the broader layout baseline first, start with Modern CSS Layout: History, Axes, Flow, and the Mental Model You Actually Need. If your bug involves sticky headers or panels competing for visual priority, Why position: sticky Is Not Working and How to Fix It is the best companion page.

1. What z-index actually controls

Every element already lives in a three-dimensional painting order:

  • inline direction
  • block direction
  • stacking direction

That third direction is what developers usually describe as "front" and "back." z-index lets you influence that order, but only when the browser is comparing boxes in a stacking context where the value matters.

By default, many elements use z-index: auto, and the browser paints them according to normal painting rules and source order. That is why a later sibling often appears on top when boxes overlap even though nobody explicitly set a layer number.

The first practical takeaway is this:

z-index is a comparison tool, not a magic "bring to front" button.

2. When z-index can actually take effect

The most common reason z-index looks broken is that the element is not participating in stacking the way you think it is.

In normal flow, a plain static element does not become a positioned layer just because you gave it a number:

.card {
  z-index: 10;
}

That usually does nothing on its own.

The typical fix is to give the element a non-static positioning mode:

.card {
  position: relative;
  z-index: 10;
}

There are two important exceptions worth remembering:

  • Flex items can use z-index even when they are not explicitly positioned
  • Grid items can also use z-index without adding position: relative

That is why overlap handling in Grid compositions can feel cleaner than older absolute-positioning hacks. If you want the Grid-specific version of controlled overlap, continue with CSS Grid Placement, Overlap, and Alignment: How Items Actually Land Where You Expect.

3. The rule that explains almost every confusing case: stacking contexts

A stacking context is a self-contained layer group.

Inside that group, descendants are compared with each other. But once the browser finishes painting that group, the whole thing is treated as one unit in its parent stacking context.

That is the key reason huge z-index values can still lose.

Imagine this structure:

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

<main class="main">
  <div class="tooltip">Tooltip</div>
</main>
.header {
  position: relative;
  z-index: 2;
}

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

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

At first glance, 999 looks like it should win.

It does not.

The reason is that .tooltip is not competing directly with .header. Its parent branch, .main, is competing with .header, and .main already lost with z-index: 1.

So the real comparison is:

  • .header at level 2
  • the entire .main stacking context at level 1

Inside .main, the tooltip can absolutely rise above other descendants. It just cannot escape its parent branch and outrank a sibling stacking context that is already above it.

4. Failure mode one: z-index on a static element does nothing

This is the first thing to check when an element refuses to move in front of a sibling.

.badge {
  z-index: 99;
}

If .badge is a normal static box, the z-index value alone is often irrelevant.

The smallest safe fix is usually:

.badge {
  position: relative;
  z-index: 99;
}

That is a good default because position: relative creates the needed positioning behavior without forcing offsets like top or left.

If the element is already a Flex item or Grid item, this particular fix may not be necessary. In that case, look for one of the next failure modes instead.

5. Failure mode two: a parent stacking context is trapping the child

This is the bug behind most "I already used a bigger number" complaints.

Consider a modal trigger or tooltip inside a container that already created its own stacking context:

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

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

If another sibling branch of the page sits above .shell, the popover is stuck below it no matter how large the child's value becomes.

The fixes are structural, not numeric:

  • remove the unnecessary z-index from the ancestor that is trapping the child
  • move the overlay to a higher DOM branch
  • or raise the ancestor stacking context itself above the competing sibling

The first option is often the cleanest. If a wrapper does not need a z-index, do not give it one just because it "might help later." Extra stacking contexts make later debugging harder.

6. Failure mode three: another property created a stacking context without you noticing

Sometimes the problem is not the z-index declaration at all. The problem is that another property quietly created a new stacking context, changing which elements can compare with which.

Common triggers include:

  • position: relative or position: absolute with a non-auto z-index
  • position: fixed
  • position: sticky
  • opacity less than 1
  • transform other than none
  • filter or backdrop-filter
  • mix-blend-mode other than normal
  • isolation: isolate
  • contain: layout or contain: paint
  • will-change when it prepares one of those stacking-context-creating properties

This is why a harmless-looking visual tweak can suddenly change layering:

.card {
  transform: translateY(-2px);
}

That may solve a hover effect, but it also creates a new stacking context. If the card contains an overlay, badge, pseudo-element, or dropdown, the layering rules can change immediately.

Two practical habits help a lot here:

  1. Check whether an ancestor recently gained transform, opacity, filter, or an explicit z-index
  2. Create stacking contexts on purpose, not by accident

If your goal is simply to isolate one card or panel so its children stack safely inside it, isolation: isolate is often the most honest tool because it says exactly what you mean:

.card {
  isolation: isolate;
}

Unlike properties such as opacity or transform, it does not also change the visual rendering just to get the stacking side effect.

7. Failure mode four: negative z-index cannot escape a parent stacking context

Negative z-index is useful when a decorative pseudo-element should sit behind an element's content:

.card {
  position: relative;
}

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

That can work well, but only if the surrounding stacking rules allow it.

Developers often expect z-index: -1 to mean "put this behind everything." That is not what it means. It only moves the element backward inside the current stacking context.

So if the parent already created a stacking context, the pseudo-element may end up:

  • behind the parent's content but still inside that context
  • unable to move beneath outside siblings
  • or visually hidden in a way that feels inconsistent

This happens a lot when the parent uses transform for centering:

.card {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

That transform creates a stacking context. If a child pseudo-element uses z-index: -1, it can no longer behave like a background that slips behind the outer world. It is still trapped inside .card's stacking context.

Two good fixes are common:

  • move the stacking-context-creating behavior to a wrapper
  • or remove the property that created the stacking context if the layout allows it

For example:

<div class="card-wrap">
  <div class="card">Card content</div>
</div>
.card-wrap {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.card {
  position: relative;
}

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

Now the centering logic lives on the wrapper, and the inner .card can manage its decorative layering more predictably.

8. A compact debugging checklist for real projects

When z-index feels broken, run through these questions in order:

  1. Is the element positioned, or is it a Flex or Grid item that can use z-index directly?
  2. Is the element inside a stacking context that is already below a competing sibling branch?
  3. Did an ancestor gain transform, opacity, filter, contain, or an explicit z-index?
  4. Are you trying to compare two elements that do not belong to the same stacking context?
  5. Are you expecting a negative z-index value to escape its parent context?

That checklist usually finds the cause much faster than increasing 999 to 999999.

9. Practical fixes that age well

Some fixes are much more reliable than others.

Good long-term fixes:

  • remove unnecessary ancestor z-index values
  • use position: relative only where an element genuinely needs layering or an absolute-positioning anchor
  • use isolation: isolate when you want a deliberate local stacking context
  • move overlays higher in the DOM when they need to compete with page-level UI
  • keep decorative negative-layer pseudo-elements away from parents that already created stacking contexts

Less reliable fixes:

  • increasing the number without checking the parent context
  • adding random transform rules just to "force" a layer
  • creating extra wrappers with arbitrary z-index values and hoping they cancel out

10. The mental model worth keeping

Most z-index bugs are not really about the number you chose. They are about the layer group the element belongs to.

If you keep one rule from this page, make it this one:

A child can only win inside its own stacking context. It cannot out-rank a sibling branch whose parent is already above it.

Once you debug from that level instead of from the number alone, overlap bugs become much easier to explain and fix.

The best next step is to pair this with Why position: sticky Is Not Working and How to Fix It for positioning-related failures, or CSS Grid Placement, Overlap, and Alignment: How Items Actually Land Where You Expect if your layering problem lives inside Grid rather than general page structure.

Publisher and editor

DevDepth Publisher

Independent publisher and frontend engineering writer

DevDepth is maintained as an independent frontend engineering publication focused on React internals, CSS layout systems, and production debugging guides.

React internalsCSS layout systemsfrontend debuggingrendering and performance reasoning

Each article is reviewed before publication for technical accuracy, explanation quality, metadata clarity, and internal-link fit within the current archive.

Last editorial review: Mar 20, 2026