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:
The element must participate in a stacking context. For most elements, this means having a
positionvalue other thanstatic(the default). Without it,z-indexis ignored entirely.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: 9999inside a parent withz-index: 1will never appear above a sibling of that parent withz-index: 2. The parent’sz-indexcaps 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:
opacitywith a value less than1transformwith any value other thannonefilterwith any value other thannonebackdrop-filterwith any value other thannoneperspectivewith any value other thannoneclip-pathwith any value other thannonemask/mask-imagewith any valuemix-blend-modewith any value other thannormalisolation: isolatewill-changewhen set to a property that would create a stacking context (e.g.,will-change: transform)containwithlayout,paint, orstrict
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:
- Does it have
positionset? If not,z-indexwon’t work (unless it’s a flex/grid child). - What is its computed
z-indexvalue? 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-indexcombined withpositionother thanstaticopacityless than1transform,filter,perspective,clip-pathset to anything other thannoneisolation: isolatewill-changeset to a stacking-related propertyposition: fixedorposition: sticky(these always create a stacking context)contain: layout,contain: paint, orcontain: 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