Why z-index Isn’t Working
Quick answer: z-index is not a global ranking. It only ranks an element against its siblings inside the same stacking context. If a modal with z-index: 9999 renders behind a header with z-index: 1, an ancestor of the modal created a stacking context — usually a transform, an opacity below 1, or a filter — and the whole subtree got sorted at that ancestor’s level instead.
The reason raising the number never helps is that you are competing in the wrong bracket. Once an ancestor establishes a stacking context, every descendant is sealed inside it. 9999 and 1 both mean “top of my own little box”, and that box is what gets placed on the page.
The one rule
A stacking context is a self-contained painting group. Elements inside it are ordered against each other; the group as a whole is then ordered against its own siblings, as a single unit. It behaves like a nested list: 2.9999 still sorts below 3, no matter how many nines you add.
<header style="position: relative; z-index: 2">…</header>
<div style="transform: translateZ(0)"> <!-- new stacking context, z-index auto -->
<div class="modal" style="z-index: 9999">…</div>
</div>
The wrapper has no z-index, so it sorts at the level of an auto-positioned element — below the header at 2. The modal is inside it, so it is painted as part of that group. Its 9999 only ever competed against its own siblings, of which there are none.
Watch it break
Stacking context breaker
The purple box has z-index: 9999. The bar has z-index: 1. Apply a property to the dashed parent and watch 9999 lose.
Everything that creates a stacking context
The list is longer than most people expect, and several entries are properties you would never associate with layering:
| Trigger | Why it is a surprise |
|---|---|
The root <html> element | Always there. The outermost context. |
position: relative|absolute with a z-index other than auto | The expected one. |
position: fixed or sticky | No z-index needed — position alone does it. |
opacity less than 1 | An element at opacity: 0.99 traps its whole subtree. |
transform, scale, rotate, translate, perspective | The most common cause in practice. Any animation library sets these. |
filter, backdrop-filter | A single filter: blur() on a wrapper is enough. |
mix-blend-mode other than normal | |
isolation: isolate | The only one whose purpose is this. See below. |
will-change naming any of the above | Fires even before the property changes. A performance hint with a layout side effect. |
contain: layout|paint|strict|content | |
content-visibility: auto | |
A flex or grid item with a z-index other than auto | Works without position — the one place z-index applies to a static element. |
container-type. Container queries originally applied layout containment, which did create a stacking context — but the CSS Working Group removed that, Chrome shipped the change in version 129 and the other engines followed. container-type: inline-size now applies size and style containment and establishes an independent formatting context, which is not a stacking context. MDN’s stacking context page still lists it, and there is an open issue against that page. If you relied on the old behaviour, add contain: layout to get both effects back, or position: relative for just the containing block.Find the culprit
Paste this into DevTools with your element’s selector. It walks up the tree and names every ancestor that created a stacking context, with the property responsible:
(function (sel) {
var el = document.querySelector(sel);
if (!el) return console.warn('No element matches', sel);
var p = el.parentElement, hits = [];
while (p) {
var s = getComputedStyle(p), why = [];
if (s.position === 'fixed' || s.position === 'sticky') why.push('position: ' + s.position);
if (s.zIndex !== 'auto' && s.position !== 'static') why.push('z-index: ' + s.zIndex);
if (parseFloat(s.opacity) < 1) why.push('opacity: ' + s.opacity);
if (s.transform !== 'none') why.push('transform');
if (s.filter !== 'none') why.push('filter');
if (s.backdropFilter && s.backdropFilter !== 'none') why.push('backdrop-filter');
if (s.mixBlendMode !== 'normal') why.push('mix-blend-mode: ' + s.mixBlendMode);
if (s.isolation === 'isolate') why.push('isolation: isolate');
if (s.willChange && s.willChange !== 'auto') why.push('will-change: ' + s.willChange);
if (/layout|paint|strict|content/.test(s.contain)) why.push('contain: ' + s.contain);
if (s.perspective !== 'none') why.push('perspective');
if (why.length) hits.push({ el: p, because: why.join(', '), zIndex: s.zIndex });
p = p.parentElement;
}
if (!hits.length) return console.log('No ancestor stacking context. The problem is elsewhere.');
console.log('Nearest first — the first row is the one sealing your element in:');
console.table(hits.map(function (h) {
return { element: h.el.tagName.toLowerCase() + (h.el.className ? '.' + String(h.el.className).split(' ')[0] : ''),
because: h.because, 'its own z-index': h.zIndex };
}));
console.log(hits[0].el);
})('.your-element');
The first row is the one that matters: it is the boundary your z-index cannot cross. Its own z-index — often auto — is the number actually competing on the page.
Or skip the console. CSS DNA reads computed styles for an element and every ancestor above it in one panel, so a transform or will-change four levels up is visible at a glance instead of after ten clicks through the elements tree. Add it free.
The fixes, in order of preference
1. Move the element out
If a modal must sit above everything, it should not live inside a card, a carousel or an animated wrapper. Render it as a direct child of <body> — that is what React portals, Vue teleports and Svelte portals exist for. The problem disappears because there is no longer an ancestor to be trapped by.
2. Use the top layer
Better still, opt out of stacking contexts entirely. Elements promoted to the browser’s top layer render above the entire document regardless of where they sit in the DOM and regardless of any ancestor’s stacking context:
<div popover id="menu">…</div>
<button popovertarget="menu">Open</button>
<!-- or, for a modal -->
<dialog id="confirm">…</dialog>
<script>confirm.showModal();</script>
Both give you top-layer rendering, and showModal() adds focus trapping and Esc to close for free. The Popover API is supported in Chrome and Edge 114, Safari 17.0 and Firefox 125 — about 91% globally. Note that <dialog> only reaches the top layer via showModal(); setting the open attribute renders it inline like any other element, where it is as trappable as anything else.
3. Isolate deliberately
isolation: isolate creates a stacking context and nothing else — no transform, no opacity change, no paint cost. Use it on a component root to guarantee that whatever the component does with z-index internally cannot leak out or be broken from outside:
.card {
isolation: isolate; /* internal z-indexes stay internal */
}
This turns stacking contexts from a thing that happens to you into a thing you declare. Every component gets a small integer scale of its own — 1, 2, 3 — and no one ever needs 9999.
4. Give the ancestor the z-index instead
Sometimes you cannot move the element and cannot use the top layer. In that case, stop styling the child and raise the ancestor that is actually competing:
.animated-wrapper { /* the one with the transform */
position: relative;
z-index: 10; /* now IT outranks the header */
}
It works, but it is the least good option — you have made a wrapper’s stacking level part of your global layering scheme, and the next person to touch it will not know why.
Two smaller reasons z-index does nothing
- The element is
position: static.z-indexhas no effect on statically positioned elements. Addposition: relative— it changes nothing visually on its own. The exception is flex and grid items, wherez-indexworks without anypositionat all. - The elements do not overlap. Layering only shows when boxes occupy the same pixels. If the “hidden” element is actually being clipped by an
overflowor pushed out of view, noz-indexwill bring it back — that is an overflow problem, not a stacking one.
A scale worth adopting
Most z-index pain is self-inflicted: values escalate because nobody knows what the current maximum is. Define the whole scale once as custom properties and never write a raw number again:
:root {
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-toast: 50;
}
Six values, no gaps to fill, and a grep-able name at every use site. Combine it with isolation: isolate on component roots and the global scale only ever needs to describe things that are genuinely global.
Frequently asked questions
Why is z-index not working?
Either the element is position: static, where z-index has no effect, or an ancestor created a stacking context that seals the element inside it. In the second case the element’s z-index only ranks it against its own siblings, and the ancestor’s stacking level is what competes on the page — so raising the number cannot help.
Why does z-index: 9999 not work?
Because it is competing in the wrong group. Stacking contexts nest like a numbered outline: an element inside a context that itself sits at level 1 can never paint above a sibling of that context at level 2, whatever its own value. Find the nearest ancestor with a transform, an opacity below 1, a filter, will-change or position: fixed — that is the boundary.
What creates a stacking context?
The root element; positioned elements with a z-index other than auto; position: fixed or sticky; opacity below 1; transform, scale, rotate, translate or perspective; filter and backdrop-filter; mix-blend-mode; isolation: isolate; will-change naming any of those; contain with layout, paint, strict or content; content-visibility: auto; and flex or grid items with an explicit z-index.
Does container-type create a stacking context?
Not any more. Container queries originally applied layout containment, which did create one, but the CSS Working Group removed that requirement and Chrome shipped the change in version 129, with the other engines following. container-type now applies size and style containment and establishes an independent formatting context, which is a different thing. MDN’s stacking context page has not yet been updated and still lists it.
Why does transform break z-index?
Because any transform value other than none creates a stacking context on that element, sealing its descendants into a group that is then painted as a single unit. This is the most common cause in practice, since animation libraries, carousels and hover effects all set transforms — including translateZ(0) added purely as a GPU hint.
Can opacity break z-index?
Yes. Any opacity value below 1 creates a stacking context, so even opacity: 0.999 — sometimes added as a rendering workaround — traps every descendant. A fade-in animation that ends at opacity: 1 will create and destroy a stacking context as it runs, which is why some overlays flicker behind other content only while animating.
What does isolation: isolate do?
It creates a stacking context and has no other effect — no transform, no opacity change, no rendering cost. Applied to a component root, it guarantees the component’s internal z-index values stay internal and cannot be broken by, or leak into, the rest of the page. It is the intended way to opt into a stacking context deliberately.
Does z-index work without position?
Not on ordinary block elements — z-index is ignored when position is static. It does work on flex items and grid items without any position value, which is the one exception. For everything else, add position: relative, which has no visual effect on its own.
How do I make a modal always appear on top?
Use the top layer rather than a large z-index: <dialog> opened with showModal(), or an element with the popover attribute. Both render above the entire document regardless of any ancestor’s stacking context, and showModal() also provides focus trapping and Esc-to-close. Failing that, render the modal as a direct child of <body> via a portal.
What does a negative z-index do?
It paints the element behind its parent’s background, but still inside the parent’s stacking context — so it cannot fall behind an ancestor that established one. This is the usual way to place a decorative pseudo-element under its parent’s content, and the usual reason such a decoration disappears entirely is that the parent has a background painting over it.
See every ancestor’s computed styles at once
A transform or will-change four levels up stops being a scavenger hunt — read the whole chain in one panel, computed in your browser.