Skip to main content

CSS z-index and stacking context

CSS z-index - sets the stacking order of positioned elements along the z-axis, but that comparison only happens within a stacking context, a local container that isolates its children's z-index values from the rest of the page.

Theory

TL;DR

  • z-index only compares elements that share the same stacking context, not the whole page
  • A child with z-index: 9999 inside a z-index: 1 parent loses to any element with z-index: 2 outside that parent
  • Stacking contexts are created by position + non-auto z-index, opacity < 1, transform, filter, isolation: isolate, and a few others
  • Think of them as glass elevator shafts: elements inside one shaft never directly overlap elements in another shaft
  • Fix z-index problems with isolation: isolate, not by adding bigger numbers

Quick Example

css
.parent { position: relative; z-index: 5; } /* Creates a stacking context */ .child { position: absolute; z-index: 100; } /* Trapped inside parent */ .other { position: absolute; z-index: 6; } /* Wins — compares to parent z=5, not child z=100 */

The child has z-index: 100 but still sits below .other. The browser compares .parent (z=5) against .other (z=6). The child's value does not enter that race. It only competes inside .parent.

How Stacking Contexts Work

When the browser paints the page, it sorts elements by the z-order of their stacking context root, not by every individual z-index. Each context is painted as a flat group. The browser does not look inside. A context with z-index: 1 is fully rendered before a context with z-index: 2, regardless of what individual values exist inside either one.

The root <html> element starts the first stacking context. Properties like position: relative; z-index: 1 trigger new ones recursively. Chrome (Blink engine) tracks each context as a PaintLayer object for GPU compositing.

What Creates a Stacking Context

Property / ConditionExample
Root element<html>
position + z-index (not auto)position: relative; z-index: 1
opacity < 1opacity: 0.99
transform (any value)transform: translateX(0)
filter (any value)filter: blur(0)
will-changewill-change: transform
isolation: isolateCreates a context explicitly
position: fixed or stickyAlways creates a context
Flex/grid child with z-indexz-index: 1 on a flex child

The most common surprise is opacity: 0.99. Set it for a subtle fade and it quietly creates a stacking context. Suddenly a tooltip inside disappears behind a sidebar with no obvious reason.

When to Use

  • Modal or drawer: position: fixed; z-index: 1000 at the root level. In React, use ReactDOM.createPortal(modal, document.body) to escape any parent context.
  • Nested dropdown: apply isolation: isolate to the parent component so inner z-index values do not affect elements outside.
  • Overlapping cards: position: relative; z-index: auto on siblings works fine without creating new contexts.
  • Avoid large numbers: instead of z-index: 99999, define a CSS custom property scale.
css
:root { --z-dropdown: 100; --z-modal: 400; --z-tooltip: 600; } .modal { position: fixed; z-index: var(--z-modal); } .tooltip { position: fixed; z-index: var(--z-tooltip); }

Common Mistakes

z-index on a static element

css
.box { z-index: 10; } /* Has no effect */

z-index is ignored unless position is relative, absolute, fixed, or sticky, or the element is a flex/grid child. Add position: relative and it works.

High z-index trapped in a low context

css
.parent { opacity: 0.99; } /* Creates a stacking context */ .tooltip { position: absolute; z-index: 9999; } /* Stuck inside parent */

opacity triggers a new context. Anything outside with z-index: 1 beats the tooltip. Move it to a root-level container or raise the parent's z-index. The transform trap is the bug that takes longest to find in real projects. You check the z-index, raise it, check again, and nothing changes, because the problem is three levels up in the DOM.

Negative z-index hides behind the background

css
.overlay { position: absolute; z-index: -1; }

Negative values place elements below the parent's background. That is rarely the intent. Use isolation: isolate on the parent if you need to clip a layer without z-index tricks.

Flex order vs. z-index confusion

css
.container { display: flex; } .item1 { order: 2; z-index: 10; position: relative; } .item2 { order: 1; z-index: 1; position: relative; transform: translateX(0); }

.item2 ends up on top despite z-index: 1. The transform promotes it to its own compositing layer. Developers expect DOM order and visual order to match cleanly. They do not when compositing layers are involved.

Real-World Usage

  • React Portal: ReactDOM.createPortal(modal, document.body) places the modal in the root stacking context, bypassing any parent traps
  • TailwindCSS: the z-50 utility on fixed elements in Next.js apps follows this same model
  • Bootstrap: .modal-backdrop uses z-index: 1040 inside a new context created by opacity: 0.5 on the backdrop element
  • Modern alternative: the ::backdrop pseudo-element and the Popover API handle overlays without manual z-index stacking in browsers that support them

Follow-up Questions

Q: What is the difference between z-index: auto and z-index: 0?
A: auto means the element participates in its parent's stacking context without creating a new one. 0 creates a new stacking context at z-level zero, which changes how its children stack.

Q: My modal has z-index: 9999 but it hides behind the navbar. Why?
A: The navbar or one of its ancestors likely has transform or filter, which creates a stacking context that sits higher. Inspect computed styles on every ancestor up to <body> and look for those properties.

Q: How does will-change: transform affect stacking?
A: It promotes the element to a GPU compositing layer and creates a stacking context before the animation starts. This prevents jank during transitions but can cause layout surprises if you forget the context was created ahead of time.

Q: Is there a case where z-index: auto and z-index: 0 behave identically?
A: Yes, when the element is not positioned and is not a flex or grid child. In that case, neither value does anything. The difference only appears when the element would otherwise create a stacking context.

Q: (Senior) Does z-index work across Shadow DOM boundaries?
A: No. Shadow roots create stacking contexts, and z-index does not pierce the shadow boundary. For cross-boundary stacking in Chrome 89+ you can use ::part() or slotted elements to expose specific parts of a component.

Examples

Basic stacking order

css
.card-a { position: relative; z-index: 1; background: lightblue; } .card-b { position: relative; z-index: 2; /* Appears above card-a */ background: coral; }

Two siblings, both position: relative, both in the root stacking context. Their z-index values compare directly. The higher value wins. This is the simple case that works as expected.

jsx
// Without the portal, the modal is trapped in the parent's z-index: 1 context function Dashboard() { return ( <div style={{ position: 'relative', zIndex: 1 }}> <Card style={{ position: 'relative', zIndex: 10 }}> Dashboard card </Card> {ReactDOM.createPortal( <div style={{ position: 'fixed', inset: 0, zIndex: 1000 }}> Modal content {/* Now in root context, wins over everything */} </div>, document.body )} </div> ); }

Without the portal, the modal sits inside the div with z-index: 1. Even at z-index: 1000, it cannot beat a sibling with z-index: 2 outside. The portal moves it to document.body, placing it in the root stacking context where the comparison is straightforward.

The transform trap

html
<div class="sidebar" style="position: relative; z-index: 10; transform: translateX(0);"> <!-- transform creates a stacking context here --> <div class="tooltip" style="position: absolute; z-index: 9999;"> This tooltip is trapped inside sidebar </div> </div> <div class="header" style="position: relative; z-index: 11;"> Header beats the tooltip despite tooltip z=9999 </div>

The sidebar's transform creates a stacking context. The tooltip's z-index: 9999 only counts inside the sidebar. The header compares against the sidebar (z=10), not the tooltip, and wins with z=11. Remove transform from the sidebar or increase the sidebar's z-index to fix it.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?