Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. z-index not working? It is the parent, not the number
Web Development

z-index not working? It is the parent, not the number

A child with z-index 9999 loses to a sibling with z-index 1 when the parent has opacity. Same markup without it, and 9999 wins.

By Max Arthur
Co-Founder & Content Marketer·September 17, 2026·7 Min read
White text reading 'Why your z-index is not working' on a dark purple dotted background

If z-index: 9999 is not putting your element on top, raising it to 99999 will not help. The number is almost never the problem. Two things are:

  1. The element has no position, so z-index is ignored entirely.
  2. An ancestor created a stacking context, which traps the element inside it.

The second is the one that wastes afternoons. Every result below came from asking a real browser which element it actually painted on top.

z-index needs a position

.box { z-index: 100; }              /* position: static, the default */

Measured, an element with z-index: 100 and no position lost to a positioned element with no z-index at all. The value is still reported by getComputedStyle as 100, which is part of why this is confusing: the property is set, it is readable, and it does nothing.

Give it any position other than static:

.box { position: relative; z-index: 100; }

relative is usually right, because it does not move the element or take it out of the flow.

There is one exception worth knowing. Flex and grid children honour z-index without a position. Measured, a flex child with z-index: 5 and no position painted above a positioned sibling. So if your element is a direct child of a flex or grid container, this first cause is already ruled out.

The trap: a stacking context in an ancestor

Here is the demonstration. Two structures, identical except for one property on a parent.

Parent has opacity: 0.99:

<div style="opacity: 0.99; position: relative; z-index: 1">
  <div style="position: absolute; z-index: 9999">RED</div>
</div>
<div style="position: relative; z-index: 2">
  <div style="position: absolute; z-index: 1">BLUE</div>
</div>

Painted on top: BLUE. The element with z-index: 1 beat the element with z-index: 9999.

Remove the opacity from the first parent, changing nothing else:

<div style="position: relative">
  <div style="position: absolute; z-index: 9999">RED</div>
</div>

Painted on top: RED. The 9999 now wins.

Same children, same z-index values, opposite outcomes. The only difference is one property on a parent.

What is actually happening

A stacking context is a self-contained layer. Everything inside it is painted as a unit, and the z-index values inside are only compared with each other.

So in the first version, the browser is not comparing 9999 against 1. It is comparing the two parents: one at z-index: 1, one at z-index: 2. The second parent wins, and everything inside it paints above everything inside the first, no matter what numbers the children use. The 9999 only decides ordering against its own siblings, of which there are none.

The mental model that fixes this permanently: a child can never escape its parent's layer. Once an ancestor makes a context, your element's z-index competes only inside it.

The properties that create one silently

This is where the bug hides, because most of these are set for visual reasons by someone who was not thinking about stacking:

Property Creates a context when
position + z-index Position is not static and z-index is not auto
opacity Any value less than 1
transform Any value other than none
filter Any value other than none
will-change Naming a property that would create one
isolation Set to isolate
mix-blend-mode Any value other than normal
contain paint, layout, or strict
backdrop-filter Any value

Confirmed in the same run: a parent with transform: translateZ(0) produced exactly the same trapped result as the opacity version, and isolation: isolate did too.

Two of these deserve attention because they are so common. opacity: 0.99 is a hack people apply to force GPU rendering. transform: translateZ(0) is the same hack. Both are frequently added to fix a flicker and both silently create a stacking context, so the z-index bug appears in a component nobody touched, introduced by a change that looked purely cosmetic.

Animation libraries apply transform constantly, which is why "my modal works until it animates" is a recognisable bug report.

Debugging it

Stop adjusting the number. Instead, walk up the DOM from the element that will not rise, and at each ancestor check whether it has any property from that table. The first one that does is your context boundary, and the ordering decision is being made there, not on your element.

In DevTools, select the element and read the Computed panel for each parent in turn, looking specifically for opacity, transform, filter and isolation. Chrome's Layers panel also visualises composited layers directly, which is faster once you know what you are looking at.

The confirming test is the one from the top of this page: temporarily remove the suspect property from the ancestor. If your element jumps to the front, you have found it.

Fixing it

Move the element out. If a modal needs to be above everything, it should not live inside a card that has a transform. Render it at the top level of the document, which is exactly what a portal does in React and what the <dialog> element does natively. This is the real fix and it is why portals exist.

Raise the ancestor instead. If the element must stay where it is, put the z-index on the parent that owns the context. That is the value actually being compared.

Remove the property that created the context, if it was a leftover GPU hack that is no longer needed.

Use isolation: isolate deliberately. Creating a context on purpose, on a component root, is a good defensive habit. It means the component's internal z-index values can never leak out or fight with the rest of the page, and it does not affect rendering in any other way. It is the one entry in that table you might reach for intentionally.

The modal case, in practice

Almost everyone meets this through a dropdown or a modal that will not sit above the page, so it is worth walking through what the fix looks like.

The dropdown lives inside a card. The card has a transform, because a designer added a hover lift. The dropdown has z-index: 9999 and appears behind the next card anyway, because it is trapped in the card's context and the cards are ordered by their own position in the document.

Raising the dropdown's z-index cannot work, because that number is only ever compared with its siblings inside the card.

The three real options:

Render it outside the card. In React that is createPortal, which puts the DOM node elsewhere in the document while keeping it in the component tree for props and events. The node ends up as a child of <body>, where nothing traps it. This is the standard answer and the reason portals exist as a feature at all.

Use the platform. The <dialog> element with showModal() renders in the browser's top layer, which sits above the entire page regardless of stacking contexts, and it brings focus trapping and escape-to-close with it. Modern CSS also exposes this through popover. When the requirement is genuinely "above everything", this is less work than any z-index scheme.

Move the transform. If the lift effect can live on an inner wrapper rather than the card root, the card stops creating a context and the problem disappears.

What all three have in common is that none of them changes a z-index value. If your fix involves picking a bigger number, it is worth checking that you have found the real boundary first.

A note on the numbers themselves

Once contexts are understood, most codebases need very few values. A scale of 1, 10, 100 for local layering, plus a documented value for overlays, covers nearly everything.

z-index: 9999 is almost always a sign that someone was fighting a stacking context and lost, then left the number behind. If you find one, the useful question is not whether it is high enough. It is which ancestor made it necessary.

Want to try these layouts and watch what paints on top? Start with the CSS track, or read the complete flexbox guide for the layout modes that change how stacking behaves.

Keep reading

More from the blog

White text reading 'CSS variables explained' on a dark purple dotted background
September 16, 2026·10 min readWeb Development

CSS variables in practice: scope, fallbacks and theming

Custom properties inherit and cascade, which is what separates them from Sass variables. An invalid value falls back further than you expect.

Read more
White text reading 'CSS units explained px, rem, em and vh' on a dark purple dotted background
September 15, 2026·10 min readWeb Development

CSS units: px, rem, em, vh and when to use each

CSS units compared: nested em compounds to 67.5px where rem stays at 24px. That difference is the whole reason to prefer one over the other.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentReact DeveloperPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs Mimo
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs Sololearn
Devpuff vs freeCodeCamp
Read All Comparisons