Fix: CSS z-index Not Working

The Error

You set a high z-index on an element, expecting it to appear on top of everything else:

.modal {
  z-index: 9999;
}

Nothing happens. The element stays behind other content. You bump it to z-index: 999999. Still behind. You try z-index: 2147483647 (the max 32-bit integer). Still nothing.

The number doesn’t matter. The problem is almost never about the value of z-index — it’s about stacking contexts.

Why This Happens

z-index only works under specific conditions. When those conditions aren’t met, the property is silently ignored — no error, no warning, no DevTools hint.

Two things must be true for z-index to have any effect:

  1. The element must participate in a stacking context. For most elements, this means having a position value other than static (the default). Without it, z-index is ignored entirely.

  2. The element’s stacking context must be the right one. An element can only compete with siblings in the same stacking context. A z-index: 9999 inside a parent with z-index: 1 will never appear above a sibling of that parent with z-index: 2. The parent’s z-index caps everything inside it.

Think of stacking contexts like folders on a desk. You can reorder papers within a folder, but the folder itself has a position in the pile. No matter how you arrange papers inside folder A, they’ll all stay below folder B if folder B is on top.

Fix

1. Add position to the Element

This is the most common cause. z-index has no effect on elements with position: static (the default).

Broken code:

.tooltip {
  z-index: 100; /* ignored — position is static */
}

Fix — add a position value:

.tooltip {
  position: relative; /* now z-index works */
  z-index: 100;
}

Any of these values will make z-index work: relative, absolute, fixed, or sticky.

Use position: relative when you want the element to stay in the normal document flow but still respond to z-index. It won’t move the element unless you also set top, left, right, or bottom.

2. Check the Parent’s Stacking Context

This is the most misunderstood cause. Your element’s z-index only competes with other elements in the same stacking context. If a parent element creates its own stacking context, your element is trapped inside it.

Broken code:

<div class="sidebar" style="position: relative; z-index: 1;">
  <div class="dropdown" style="position: absolute; z-index: 9999;">
    <!-- This will NEVER appear above .main-content -->
  </div>
</div>

<div class="main-content" style="position: relative; z-index: 2;">
  <!-- This sits above .sidebar and everything inside it -->
</div>

.dropdown has z-index: 9999, but its parent .sidebar has z-index: 1. The browser compares .sidebar (z-index: 1) against .main-content (z-index: 2) at the parent level. .main-content wins. Everything inside .sidebar — including .dropdown — is rendered below .main-content.

Fix — raise the parent’s z-index:

.sidebar {
  position: relative;
  z-index: 3; /* now higher than .main-content */
}

Or remove the parent’s stacking context if it doesn’t need one:

.sidebar {
  position: relative;
  /* remove z-index entirely — no stacking context created */
}

Without a z-index, the .sidebar doesn’t create a stacking context, and .dropdown competes at the root level where its z-index: 9999 works as expected.

Note: You can only remove the parent’s z-index if it doesn’t need to be stacked itself. If both the parent and the child need specific stacking, you may need to restructure your HTML so the child is not nested inside the parent.

3. Remove Properties That Silently Create Stacking Contexts

Several CSS properties create a new stacking context even without z-index being set. This catches people off guard because the property seems unrelated to stacking.

These properties create a new stacking context on an element:

  • opacity with a value less than 1
  • transform with any value other than none
  • filter with any value other than none
  • backdrop-filter with any value other than none
  • perspective with any value other than none
  • clip-path with any value other than none
  • mask / mask-image with any value
  • mix-blend-mode with any value other than normal
  • isolation: isolate
  • will-change when set to a property that would create a stacking context (e.g., will-change: transform)
  • contain with layout, paint, or strict

Broken code — opacity creating a hidden stacking context:

.card {
  opacity: 0.99; /* creates a stacking context */
}

.card .popup {
  position: absolute;
  z-index: 9999; /* trapped inside .card's stacking context */
}

Even opacity: 0.99 — visually identical to opacity: 1 — creates a stacking context. This is a common source of confusion when adding fade-in animations.

Broken code — transform creating a hidden stacking context:

.header {
  transform: translateZ(0); /* "GPU acceleration hack" — creates stacking context */
}

.header .dropdown-menu {
  position: absolute;
  z-index: 1000; /* trapped inside .header */
}

The transform: translateZ(0) trick (sometimes used for GPU acceleration) creates a stacking context on .header, trapping everything inside it.

Fix — remove the property if possible, or restructure:

.header {
  /* Remove transform: translateZ(0); if not needed */
}

If you need the transform for animation purposes, move the dropdown outside the transformed parent in the HTML:

<!-- Before: dropdown trapped inside transformed header -->
<header class="header">
  <nav>...</nav>
  <div class="dropdown-menu">...</div>
</header>

<!-- After: dropdown is a sibling, free from header's stacking context -->
<header class="header">
  <nav>...</nav>
</header>
<div class="dropdown-menu">...</div>

4. Flexbox and Grid Children: z-index Works Without position

Here’s an exception that trips people up in the opposite direction. Flex items and grid items can use z-index without setting position.

.flex-container {
  display: flex;
}

.flex-container .item {
  /* No position needed — z-index works on flex items */
  z-index: 1;
}

This is defined in the spec: flex items and grid items establish a stacking context when z-index is set, even if their position is static.

Where this matters: If you’re debugging z-index on a flex or grid child and you add position: relative thinking it’ll fix the issue — it won’t help because z-index was already working. The problem is likely a parent stacking context (see Fix 2).

5. Use isolation: isolate to Control Stacking Contexts

The isolation property exists specifically to create a stacking context without side effects. Use it when you want to contain z-index stacking within a component without affecting the rest of the page.

.modal-wrapper {
  isolation: isolate;
}

This creates a stacking context on .modal-wrapper without changing its opacity, transform, or position. Elements inside it can use z-index to layer among themselves, and none of them will leak out and interfere with elements outside .modal-wrapper.

Practical example — preventing a component’s internal z-index from leaking:

/* Component styles */
.card {
  isolation: isolate; /* contains z-index stacking */
}

.card .background {
  position: absolute;
  z-index: -1; /* stays behind card content, doesn't fall behind elements outside .card */
}

.card .content {
  position: relative;
  z-index: 0;
}

Without isolation: isolate on .card, the z-index: -1 on .background could fall behind elements outside the card entirely, because it would be competing in the parent’s stacking context.

6. Move the Element Outside Its Current Stacking Context

Sometimes the cleanest fix is structural. If an element needs to appear above everything — like a modal, tooltip, or dropdown — it should live at the top level of the DOM, not nested deep inside a component tree.

Using a portal in React:

import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal-overlay">
      <div className="modal">{children}</div>
    </div>,
    document.body
  );
}

If your portal renders differently on the server and client, you may also encounter hydration mismatch errors in Next.js.

Using a top-level element in plain HTML:

<body>
  <div class="app">
    <!-- your app content with its own stacking contexts -->
  </div>

  <!-- modal lives outside the app tree — no stacking context traps it -->
  <div class="modal-overlay">
    <div class="modal">...</div>
  </div>
</body>
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
}

By placing the modal at the root level, its z-index competes at the top of the document, not inside some nested stacking context.

How to Debug z-index Issues

Step 1: Inspect the Element in DevTools

Open your browser’s DevTools, select the element that isn’t stacking correctly, and check:

  1. Does it have position set? If not, z-index won’t work (unless it’s a flex/grid child).
  2. What is its computed z-index value? Check the Computed tab, not just the Styles tab. A rule further down might be overriding your value.

Step 2: Walk Up the DOM Tree

Select each ancestor element and check whether it creates a stacking context. Look for:

  • z-index combined with position other than static
  • opacity less than 1
  • transform, filter, perspective, clip-path set to anything other than none
  • isolation: isolate
  • will-change set to a stacking-related property
  • position: fixed or position: sticky (these always create a stacking context)
  • contain: layout, contain: paint, or contain: strict

Any of these on a parent creates a new stacking context that traps your element.

Step 3: Use the Stacking Context Inspector

Chrome/Edge: Search for “stacking context” extensions in the Chrome Web Store. The “CSS Stacking Context Inspector” extension highlights all stacking contexts on the page and shows their hierarchy.

Firefox: Firefox DevTools show a “stacking context” badge on elements in the inspector when they create one.

Still Not Working?

Check for !important Overrides

Another stylesheet might be overriding your z-index with !important:

/* Some framework or reset CSS you forgot about */
.widget * {
  z-index: 0 !important;
}

In DevTools, look at the Styles panel for your element. Crossed-out z-index declarations mean they’re being overridden. Check all matching selectors.

Check position: fixed Elements

position: fixed elements are positioned relative to the viewport, but they still participate in the stacking context of their nearest ancestor that creates one. This means a position: fixed modal inside a transformed parent won’t behave as expected — it loses its viewport-relative positioning and gets trapped in the parent’s stacking context.

.animated-page {
  transform: translateX(0); /* creates stacking context */
}

.animated-page .modal {
  position: fixed; /* broken — no longer relative to viewport */
  z-index: 9999;   /* trapped inside .animated-page's stacking context */
}

Fix: Move the fixed element outside the transformed parent in the DOM (see Fix 6).

Negative z-index Falling Behind the Background

If you set z-index: -1 on an element to place it behind its parent’s content, it might disappear behind the parent entirely:

.parent {
  background: white;
}

.parent .behind {
  position: absolute;
  z-index: -1; /* falls behind .parent's background */
}

The element with z-index: -1 is rendered below the parent’s background because the parent doesn’t create a stacking context. The element drops down to the parent’s parent context.

Fix — create a stacking context on the parent:

.parent {
  background: white;
  isolation: isolate; /* or position: relative; z-index: 0; */
}

.parent .behind {
  position: absolute;
  z-index: -1; /* now behind content but above parent's background */
}

Animations and Transitions Temporarily Breaking z-index

CSS animations and transitions that involve transform, opacity, or filter create stacking contexts only while running. This means z-index stacking can change mid-animation, causing elements to visually “jump” between layers.

If a dropdown works fine until a nearby element starts animating, the animation is temporarily creating a stacking context that interferes. Add isolation: isolate to the animated element’s parent to contain the stacking context, or use will-change to make the stacking context permanent (so the layout stays consistent):

.animated-element {
  will-change: transform; /* permanent stacking context — no visual jump */
}

Warning: Don’t apply will-change to too many elements. It consumes GPU memory. Only use it on elements that actually animate. If your CSS or JS bundle is failing to load entirely, the z-index issue might be secondary to a failed chunk load.


Related: Fix: TypeError: Cannot read properties of undefined | Fix: Vite Failed to Resolve Import