How to Fix CSS Z-Index Not Working: A Complete Troubleshooting Guide
If you’ve ever set z-index: 9999 on an element and watched it stubbornly sit behind another element with z-index: 1, you’re not alone. CSS stacking is one of the most misunderstood parts of front-end development, and the keyword search for “how to fix css z-index not working” returns thousands of frustrated developers every month.
In this guide, I’ll walk you through the actual mechanics of stacking contexts, the most common reasons your z-index is being ignored, and exact fixes you can apply today. By the end, you’ll have a mental model that prevents this issue from ever surprising you again.
Understanding Why Z-Index Fails
Before we jump into fixes, let’s understand what’s actually happening. z-index doesn’t work in isolation. It’s part of a larger system called the stacking context — a three-dimensional conceptualization of HTML elements along the Z-axis.
The single biggest reason z-index appears to “not work” is that developers treat it as a global value, when in reality z-index only compares within the same stacking context. Think of stacking contexts like nested folders: a file inside Folder A can’t be “above” a file in Folder B, no matter what number you assign to it — you’d have to move the folders themselves.
Here’s the visual metaphor I use with my team:
Document Root (Root Stacking Context)
├── Header (creates a new stacking context via transform)
│ ├── Logo (z-index: 10) ← only matters INSIDE Header
│ └── Nav (z-index: 5) ← only matters INSIDE Header
└── Modal (z-index: 1000)
└── Modal close button (z-index: 9999) ← only matters INSIDE Modal
The Modal close button has z-index: 9999, but if the Modal itself has a lower z-index than the Header, the button will still sit behind the Logo. This is the trap.
Root Cause Analysis: The Three Main Culprits
When z-index isn’t working, the cause will fall into one of these three buckets:
1. Missing position Value
This is the #1 cause for beginners. z-index only works on positioned elements. The default position: static ignores z-index completely.
/* This DOES NOT WORK */
.modal {
z-index: 9999;
/* position: static by default */
}
/* This works */
.modal {
position: relative; /* or absolute, fixed, sticky */
z-index: 9999;
}
In modern CSS (2026), flex items and grid items also accept z-index without needing a position declaration, which confuses people further. Just remember: regular block elements need positioning first.
2. An Ancestor Created a New Stacking Context
This is the most insidious cause. Many CSS properties silently create a new stacking context. If your element is nested inside one of these, its z-index is “trapped” — it can only be compared against siblings within that same context.
The properties that create stacking contexts (as of the CSS Stacking Context spec, updated in 2024) include:
| Property | Condition |
|---|---|
position |
absolute or relative with z-index ≠ auto |
position |
fixed or sticky (always, regardless of z-index) |
opacity |
Any value less than 1 |
transform |
Any value other than none |
filter |
Any value other than none |
backdrop-filter |
Any value other than none |
mix-blend-mode |
Any value other than normal |
isolation |
isolate |
will-change |
Any of the above property names |
clip-path |
Any value other than none |
mask / mask-image |
Any value other than none |
contain |
layout, paint, or strict |
| Flex/Grid item | With z-index ≠ auto |
I’ve lost hours debugging modals that wouldn’t sit above sticky headers, only to find a transform: translateZ(0) somewhere up the tree used for “GPU acceleration.” That single line created a stacking context and trapped my modal.
3. Comparing at the Wrong Level
Even if your element is positioned correctly and you understand stacking contexts, you might be comparing two elements that aren’t actually siblings in the same context. An element with z-index: 1 in one stacking context can never be evaluated against an element with z-index: 10 in another — you must compare the contexts themselves.
Step-by-Step Solutions (From Most Common to Edge Cases)
Step 1: Verify the Element Has a Position
Open DevTools, inspect your element, and confirm it has position: relative, absolute, fixed, or sticky. If it doesn’t, add one.
.dropdown {
position: absolute;
z-index: 100;
}
If your element is a flex or grid item, you can skip this step — they accept z-index natively.
How to verify in DevTools: Right-click the element → Inspect → look at the “Computed” tab. If position is static, that’s your problem.
Step 2: Walk Up the DOM Tree to Find Stacking Context Creators
This is the step most people skip. Use DevTools to inspect not just your element, but every ancestor. Look for any of the properties listed in the table above.
Here’s a practical debugging approach using the Chrome DevTools console:
function findStackingContexts(el) {
const contexts = [];
let current = el.parentElement;
while (current && current !== document.body) {
const style = getComputedStyle(current);
const createsContext =
(style.position === 'fixed' || style.position === 'sticky') ||
(style.zIndex !== 'auto' &&
(style.position === 'relative' || style.position === 'absolute')) ||
(parseFloat(style.opacity) < 1) ||
(style.transform !== 'none') ||
(style.filter !== 'none') ||
(style.backdropFilter !== 'none') ||
(style.mixBlendMode !== 'normal') ||
(style.isolation === 'isolate') ||
(style.clipPath !== 'none') ||
(style.maskImage !== 'none') ||
(style.willChange.includes('transform') ||
style.willChange.includes('opacity') ||
style.willChange.includes('filter'));
if (createsContext) {
contexts.push({
element: current,
reason: getReason(style),
zIndex: style.zIndex
});
}
current = current.parentElement;
}
console.table(contexts);
return contexts;
}
function getReason(style) {
if (style.position === 'fixed' || style.position === 'sticky')
return `position: ${style.position}`;
if (parseFloat(style.opacity) < 1)
return `opacity: ${style.opacity}`;
if (style.transform !== 'none')
return `transform: ${style.transform}`;
if (style.filter !== 'none')
return `filter: ${style.filter}`;
if (style.zIndex !== 'auto')
return `position + z-index`;
return 'other';
}
// Usage: findStackingContexts(document.querySelector('.my-modal'));
Save this snippet. I use it weekly. Paste it into your console, pass your element, and it’ll show you every ancestor that’s trapping your z-index.
Step 3: Restructure Your DOM If Necessary
If you find a stacking context that’s trapping your element, you have two options:
Option A: Move your element out of the trapping context.
This is the cleanest fix. If your modal lives inside a <header> with transform: translateY(-2px), move it to the end of <body>:
<!-- Before: modal is trapped -->
<header style="transform: translateY(-2px);">
<div class="modal">I'm stuck!</div>
</header>
<!-- After: modal escapes -->
<header style="transform: translateY(-2px);">
<!-- header content -->
</header>
<div class="modal">I'm free!</div>
Modern frameworks like React, Vue, and Svelte all support portals/teleports for exactly this use case:
// React example
import { createPortal } from 'react-dom';
function Modal({ children }) {
return createPortal(
<div className="modal">{children}</div>,
document.body
);
}
<!-- Vue example -->
<template>
<Teleport to="body">
<div class="modal">
<slot />
</div>
</Teleport>
</template>
Option B: Remove the stacking-context-creating property from the ancestor.
If you don’t actually need that transform, filter, or opacity: 0.99 hack, remove it. Sometimes these are leftover from old “GPU acceleration” tricks that modern browsers don’t need anymore.
Step 4: Adjust Z-Index at the Right Level
If you can’t move your element, you need to make sure the parent stacking contexts themselves are properly z-indexed relative to each other.
/* Don't try to fix this by raising the modal's z-index */
.header {
position: sticky;
top: 0;
transform: translateZ(0); /* creates stacking context */
z-index: 100;
}
.modal-container {
position: fixed;
/* The CONTAINER needs to be higher than header */
z-index: 200;
}
.modal {
/* Now this works because its parent is above the header */
z-index: 1;
}
Step 5: Check for Browser-Specific Quirks
A few edge cases persist in 2026:
Native form controls (especially <select>, date pickers, video controls) sometimes render in their own internal stacking contexts. If a native dropdown appears over your modal, you can’t fix it with z-index — you need to use a custom dropdown component.
<dialog> element in top layer: The native <dialog> element (when opened with showModal()) lives in the “top layer,” which is above ALL other stacking contexts regardless of z-index. This is actually a feature — it’s why I recommend using <dialog> for modals in modern apps:
<dialog id="myModal">
<p>This will always sit above everything else.</p>
</dialog>
<script>
document.querySelector('#openBtn').addEventListener('click', () => {
document.querySelector('#myModal').showModal();
});
</script>
Safari and position: sticky: Safari had a long-standing bug where position: sticky elements didn’t always behave as expected with z-index. As of Safari 17.x this is resolved, but if you’re supporting older versions, you may need to use position: fixed with scroll listeners as a fallback.
Step 6: Watch for will-change Abuse
will-change is a performance hint, but it creates stacking contexts as a side effect. I’ve seen developers sprinkle will-change: transform on dozens of elements “just in case,” and then wonder why their z-index is broken.
/* ❌ Don't do this */
.card {
will-change: transform; /* creates stacking context on every card */
}
/* ✅ Apply only when needed, and remove when done */
.card.entering {
will-change: transform;
transition: transform 0.3s;
}
.card.entered {
will-change: auto; /* let it go */
}
Edge Cases Worth Knowing
The “Sticky Header Under Hero Image” Problem
This is a classic. Your sticky header sits behind a hero image with transform for parallax:
/* Hero creates a stacking context via transform */
.hero {
position: relative;
transform: translateZ(0); /* for parallax */
}
.hero-image {
position: absolute;
z-index: 1;
}
/* Header tries to sit above hero but fails */
.header {
position: sticky;
top: 0;
z-index: 1000; /* nope — hero's context beats us */
}
Fix: Either remove the transform from the hero, or make the header itself a sibling of the hero at the same DOM level, with its own stacking context that ranks higher.
The “Tooltip Behind Sibling” Problem
Tooltips often fail because they’re inside an element with overflow: hidden or overflow: auto, which can clip them or place them in unexpected stacking positions.
.card {
overflow: hidden; /* tooltip gets clipped */
position: relative;
}
.tooltip {
position: absolute;
z-index: 9999; /* still clipped by overflow */
}
Fix: Use a portal pattern or move the tooltip to position: fixed with JavaScript positioning (libraries like Floating UI or Popper handle this beautifully).
The “Modal Backdrop Over Modal” Problem
You added a backdrop with opacity: 0.5, and suddenly your modal content sits behind the backdrop:
/* Backdrop creates a stacking context via opacity */
.backdrop {
position: fixed;
inset: 0;
opacity: 0.5; /* creates stacking context! */
background: black;
z-index: 100;
}
.modal {
position: fixed;
z-index: 100; /* same number, but they're siblings */
/* modal might render behind backdrop depending on DOM order */
}
Fix: Use rgba() for the background instead of opacity, so no stacking context is created:
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5); /* no opacity, no stacking context */
z-index: 100;
}
.modal {
position: fixed;
z-index: 101; /* works as expected */
}
This is a subtle but incredibly useful trick.
Prevention Tips: Build a System That Doesn’t Break
1. Define Z-Index Tokens
Stop using magic numbers. Define a small set of z-index values as CSS custom properties, and document what each is for:
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-drawer: 300;
--z-modal: 400;
--z-toast: 500;
--z-tooltip: 600;
}
.dropdown {
position: absolute;
z-index: var(--z-dropdown);
}
.modal {
position: fixed;
z-index: var(--z-modal);
}
I keep this token scale in every project. It forces consistency and prevents the “let’s just bump it to 99999” anti-pattern.
2. Use isolation: isolate Deliberately
Instead of relying on transform or opacity to create stacking contexts (which often happens by accident), use isolation: isolate when you explicitly want one:
.widget {
isolation: isolate;
/* This widget's children won't leak z-index to the rest of the page */
}
This is self-documenting and doesn’t have visual side effects.
3. Audit for Stacking Contexts in Code Review
Add a checklist item to your PR template: “Does this PR introduce a stacking context via transform/filter/opacity that could affect z-index?” Five minutes of review saves hours of debugging.
4. Prefer Native <dialog> for Modals
Since <dialog> with showModal() lives in the top layer, it sidesteps the entire stacking context problem. As of 2026, browser support is universal (Chrome 37+, Firefox 98+, Safari 15.4+).
5. Use the Right Tool for Positioning
If you’re building tooltips, popovers, dropdowns, or menus, use the Popper API (now native in modern browsers via popover attribute and the Popover API) instead of manual z-index management:
“`html