CSS z-index Not Working: Stacking Contexts Explained

Quick answer

💡z-index only works on positioned elements — position:relative, absolute, fixed, or sticky. If an element has position:static (the default), z-index has no effect. The second most common cause is a stacking context created by opacity, transform, filter, or will-change on an ancestor: z-index is scoped within each stacking context and can never escape its parent's layer.

Error symptoms

  • Element with a high z-index still appears behind another element
  • Modal or dropdown is hidden behind a sidebar or header
  • Setting z-index:9999 has no visible effect
  • Tooltip is clipped by a card it is supposed to float above
  • Sticky header disappears behind content when scrolling
  • SVG element z-index is completely ignored

Common causes

  • Element has position:static — z-index is ignored on static elements
  • A parent or ancestor creates a new stacking context that limits the element's z-index scope
  • opacity less than 1 on a parent creates a stacking context
  • transform or filter on a parent creates a stacking context
  • will-change:transform creates a stacking context even before a transform is applied
  • z-index values are compared between different stacking contexts, not the global document

When it happens

  • After adding opacity or transform to a parent for a fade or animation effect
  • When a component library creates stacking contexts you are unaware of
  • After refactoring layout wrappers that introduced position:relative
  • When building overlays, modals, or tooltips inside deeply nested components

Examples and fixes

The most common reason z-index has no effect is a missing position declaration.

z-index ignored because position:static

❌ Wrong

<div class="card">
  <span class="badge">New</span>
</div>

.card { background: white; }
.badge {
  z-index: 10;
  background: red;
  color: white;
}

✅ Fixed

<div class="card">
  <span class="badge">New</span>
</div>

.card { position: relative; background: white; }
.badge {
  position: absolute;
  top: -8px;
  right: -8px;
  z-index: 10;
  background: red;
  color: white;
}

z-index is part of the stacking model, which only applies to positioned elements. An element with the default position:static is not part of the stacking order and z-index is silently ignored. Adding position:relative, absolute, fixed, or sticky opts the element into the stacking model and makes z-index take effect.

A parent with opacity:0.99 creates a stacking context that traps the modal's z-index.

Modal hidden by parent with opacity

❌ Wrong

<div class="page" style="opacity: 0.99">
  <div class="modal" style="z-index: 1000">
    Modal content
  </div>
</div>
<div class="overlay" style="z-index: 500">
  Overlay
</div>

✅ Fixed

<div class="page">
  <div class="modal" style="z-index: 1000">
    Modal content
  </div>
</div>
<div class="overlay" style="z-index: 500">
  Overlay
</div>

Any opacity value less than 1 — including 0.99 applied as a performance trick or animation state — causes the browser to create a new stacking context for that element. All z-index values inside the context are compared only to each other, not to elements outside. So a modal with z-index:1000 inside an opacity:0.99 parent can be behind an overlay with z-index:500 that is outside the parent. Remove opacity from ancestors of overlapping elements, or move the modal outside the stacking context.

What creates a stacking context

A stacking context is a group of elements that are rendered as a unit in the stacking order. Within a stacking context, z-index values only compete with other elements inside the same context — they have no effect on elements in parent or sibling contexts. Understanding which CSS properties create stacking contexts is the core skill for debugging z-index.

The properties that create a new stacking context are: position combined with any z-index value other than auto; opacity less than 1; any transform value (including transform:none — wait, actually transform:none does not create a context, but transform:translate(0,0) does); filter with any value other than none; will-change set to any property that would create a stacking context (such as opacity, transform, or filter); isolation:isolate; and mix-blend-mode with any value other than normal. In modern browsers, contain:layout, contain:paint, and contain:strict also create stacking contexts.

The key mental model is: z-index is local, not global. A z-index:1000 element inside a stacking context created by opacity:0.5 cannot appear above a z-index:1 element that exists in the root stacking context. The parent's stacking context renders as a single unit, and its z-index relative to siblings determines where all its children appear.

will-change:transform is especially tricky. Developers add it to elements for performance optimization before a transform is applied — but it immediately creates a stacking context, even when no transform is currently active. If a sidebar or header has will-change:transform, all its children are now inside a stacking context, and their z-index values cannot escape it. The same applies to will-change:opacity and will-change:filter — any value that references a stacking-context-creating property triggers the context early, before the property itself is applied.

Diagnosing z-index with DevTools

Start by selecting the element that is not appearing correctly. In the Computed tab, confirm that position is not static. If it is static, z-index is the least of your problems — you need to add a positioning context first.

Next, inspect the element's ancestors. Walk up the DOM tree in the Elements panel and check each ancestor's Computed styles for the stacking context triggers: opacity (anything under 1), transform (anything other than none), filter (anything other than none), will-change, isolation, and mix-blend-mode. Click through every parent, grandparent, and ancestor until you reach the document root.

Chrome DevTools has a 3D Layers view (accessible via the vertical three-dot menu in DevTools, More tools, Layers) that shows the composited layer tree. This is different from the stacking order, but it often reveals which elements are creating their own layers — a signal that they may also be creating stacking contexts.

Once you identify a stacking context creator, decide whether you can remove it. If opacity is the culprit and it was added for a hover fade, consider whether the fade can be applied via rgba() background color or a pseudo-element instead of opacity on the parent. If transform is the culprit from a performance hack, consider removing the will-change or restructuring the DOM to move the overlay outside the transformed ancestor.

Safari's Web Inspector also surfaces stacking context information but presents it differently from Chrome. In Safari, the Layers tab in Web Inspector shows composited layers and their paint boundaries. For z-index debugging on iOS devices connected via USB, use Safari's Web Inspector from macOS to inspect the device's page directly. This is the only way to catch z-index issues that appear specifically in Mobile Safari due to its handling of -webkit-overflow-scrolling and position:fixed elements near the keyboard.

Proven fixes for z-index failures

The cleanest architectural fix is to render overlays — modals, tooltips, dropdowns — at the root level of the DOM using a portal pattern. In React, ReactDOM.createPortal() renders the overlay as a child of document.body, placing it in the root stacking context where z-index competes globally. This completely bypasses the stacking context problem. The same pattern works in Vue with Teleport and in vanilla JS by appending the overlay element directly to the body.

If you cannot use a portal, use isolation:isolate. Adding isolation:isolate to an element creates a new stacking context without any visual side effects — no opacity change, no transform shift, no filter. This is the cleanest CSS-only way to explicitly scope z-index within a component. Then, ensure the element's z-index is set appropriately relative to its siblings within the isolated context.

For performance-motivated transforms (transform:translateZ(0) or will-change:transform applied to improve scrolling), evaluate whether the performance benefit is still relevant. Modern browsers no longer require this hack for most scrolling scenarios. If you can remove will-change:transform from ancestors of overlapping elements, the stacking context disappears and z-index competes globally again.

Establish a z-index scale with CSS variables. Define --z-base:1; --z-dropdown:100; --z-sticky:200; --z-modal:300; --z-toast:400; in :root and use these variables across the codebase. This prevents arbitrary high numbers (z-index:9999 is a symptom of this problem) and makes layering decisions explicit and auditable.

Another effective fix when dealing with a filter property creating an unwanted stacking context is to move the filter to a pseudo-element instead of the element itself. Apply filter:blur(8px) to a .card::after pseudo-element that is absolutely positioned to fill the card, rather than to the .card directly. Because the pseudo-element is a child of .card, only the visual effect appears on the card — the stacking context is scoped to the pseudo-element, not the card, and child overlays of the card can still use z-index freely relative to the root.

SVG, mobile, and special cases

SVG elements do not support z-index. In SVG, the painting order is determined entirely by DOM order — the last element in the markup paints on top. If you need to reorder SVG elements, you must reorder them in the DOM. Attempting to set z-index on SVG elements is silently ignored by all browsers.

On mobile browsers, fixed-position elements (position:fixed) interact with z-index in non-obvious ways when the virtual keyboard appears. On iOS Safari, raising the keyboard changes the viewport, which can cause fixed elements to reflow unexpectedly. If you have modals or overlays that use position:fixed with z-index, test them specifically with the keyboard open on a real iOS device.

iframe elements create their own stacking context. Content inside an iframe cannot escape its stacking context regardless of z-index values. If you are embedding third-party widgets via iframe, you cannot use z-index to make your page content appear above the iframe's content. You must ensure the iframe is placed in the correct DOM order relative to elements you want on top.

The @layer CSS rule introduces another layer of ordering complexity. Declarations in later layers override earlier ones regardless of specificity. If your z-index-bearing styles are in an earlier layer and a reset or base layer later overrides position to static, z-index breaks. When using @layer, organize layers from base utilities to component-specific styles and be aware that z-index depends on position being set in the final computed value.

On iOS Safari, applying a CSS filter — even filter:drop-shadow() — to a parent element that contains a position:fixed child will break the fixed positioning of that child and may also affect its z-index behavior. This is a known Safari-specific bug where the GPU compositing layer created by filter conflicts with fixed positioning. The workaround is to apply the filter to a sibling or pseudo-element rather than to the ancestor of the fixed element.

Common mistakes with stacking order

The number one mistake is using increasingly high z-index values to fight symptoms instead of identifying the root cause. z-index:9999 and z-index:99999 suggest a developer was trying to brute-force a solution. These values create technical debt because every new overlay added later must use an even higher value, leading to an arms race. Fix the stacking context instead.

Adding position:relative to fix z-index without understanding why is a related mistake. position:relative with z-index can accidentally create a new stacking context that traps child elements. Always add z-index intentionally — check that the element needs it and that adding it does not create an unwanted context for its children.

Forgetting that opacity:0 is not the same as visibility:hidden or display:none with respect to stacking contexts. An element with opacity:0 is still rendered and still creates a stacking context. If you use opacity:0 to hide elements (for example, during an animation), that element's stacking context still affects its children.

Assuming that removing a class will remove the stacking context. If a class applies opacity:0.5 and you remove it, the opacity returns to 1, and the stacking context goes away. But if the transition is still in progress (the element is animating from opacity:0.5 to opacity:1), the stacking context remains until the animation completes. This can cause brief z-index flickers during transitions.

A less obvious mistake is setting will-change:transform on a fixed-position navigation bar to improve scroll performance. While will-change:transform does create a compositing layer that helps performance, it also means any child dropdown menus that use a high z-index are now scoped inside the navigation bar's stacking context. Dropdowns that should appear above page content end up behind it. The solution is to use will-change on the individual animated element rather than the parent container, or to render dropdown content outside the navigation bar via a portal.

Best practices for predictable z-index

Use isolation:isolate on every self-contained component that manages its own internal layering. Card components, navigation menus, and widget containers should each create their own stacking context explicitly. This makes the z-index scope intentional and prevents a component's internal layers from bleeding into the global stacking order.

Move all application overlays — modals, drawers, toasts, tooltips — to the DOM root using a portal pattern or a designated overlay container at the end of the body. This single architectural decision eliminates the vast majority of z-index bugs in large applications, because all overlays compete in the same root stacking context.

Audit your z-index values as part of code review. Any z-index value above 100 or any value not defined in the shared z-index scale should trigger a question: why is this number necessary? If the answer involves fighting a stacking context, the underlying architecture needs to change, not the number.

Document stacking context creators in your codebase. When you add opacity, transform, filter, or will-change to an element, add a comment noting that this creates a stacking context. Future developers — or future you — will know to check for overlay issues when modifying that element. A one-line comment prevents hours of debugging.

Define your z-index scale as a design token system using CSS custom properties: --z-base, --z-dropdown, --z-sticky, --z-modal, --z-toast. Every positioned element in the codebase should reference one of these named tokens rather than a raw integer. Named tokens communicate intent — a developer reading z-index:var(--z-modal) immediately understands the layer's role, while z-index:300 requires context to interpret. This token approach also makes it trivial to adjust the entire scale at once if a third-party library claims a range of values you are using.

For teams using CSS Modules or CSS-in-JS, export the z-index token values from a shared JavaScript constants file so the same layer names can be used in both CSS and JavaScript (for inline styles or portals). Keeping z-index values in one canonical location prevents drift between what the stylesheet declares and what a JS-driven overlay uses.

Quick fix checklist

  • Confirm the element has a position value other than static
  • Walk up the DOM and check ancestors for opacity < 1, transform, filter, will-change
  • Move modals and overlays to the document root using a portal pattern
  • Use isolation:isolate to explicitly scope component-internal z-index
  • Replace transform:translateZ(0) performance hacks if they break z-index
  • Define a CSS variable z-index scale and stop using values above 400
  • Test SVG element ordering by reordering DOM elements, not z-index
  • Check that z-index:0 is not used where z-index:auto was intended

Related guides

Frequently asked questions

Why does z-index:9999 not work on my element?

A very high z-index value does not override stacking context boundaries. If an ancestor of your element has opacity less than 1, a transform, or other stacking context triggers, your element's z-index is scoped within that context. It cannot appear above elements in a higher-level context regardless of the number.

Does opacity:1 create a stacking context?

No. opacity:1 does not create a stacking context. Only opacity values strictly less than 1 trigger a new stacking context. This is why adding opacity:0.99 as a performance trick or animation starting state can unexpectedly break z-index for all children.

What is the cleanest way to isolate z-index inside a component?

Use isolation:isolate on the component's root element. This creates a stacking context with no visual side effects — no opacity change, no transform shift. All z-index values inside the component are then scoped to the component, preventing them from interfering with the rest of the page.

Does transform:none create a stacking context?

No, transform:none does not create a stacking context. Any transform value other than none — including transform:translate(0,0), transform:scale(1), or transform:translateZ(0) — creates a stacking context. The commonly used GPU hack transform:translateZ(0) always creates a stacking context.

Can z-index work on SVG elements?

No. SVG elements do not support z-index. Stacking order in SVG is determined solely by DOM order — later elements paint on top of earlier ones. To change the layering of SVG elements, you must reorder them in the HTML markup.

What does will-change:transform do to z-index?

will-change:transform creates a new stacking context immediately, even before any transform is applied. This is a common source of z-index bugs when will-change is added as a performance optimization. If removing will-change:transform fixes your z-index issue, you need to move the overlay outside the will-change element or use a portal.

How do I make a modal appear above everything on the page?

Render the modal as a direct child of the body element using a portal pattern — ReactDOM.createPortal in React, Teleport in Vue, or direct DOM insertion in vanilla JS. Give it position:fixed and a z-index within your documented z-index scale. This places it in the root stacking context where it competes globally.

Does position:relative with z-index:0 create a stacking context?

Yes. position:relative (or any positioned value) combined with z-index set to any integer — including 0 — creates a stacking context. Only position:relative with z-index:auto does not create one. Setting z-index:0 is not the same as z-index:auto.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.