CSS z-index and Stacking Contexts, Taken Apart in 3D

z-index orders overlapping elements, but only within the same stacking context. A child with z-index: 9999 cannot rise above an element outside its parent if that parent forms a context with a lower z-index. Contexts come from position plus z-index, opacity below 1, transform, filter, isolation: isolate and several other properties.

Why z-index seems ignored
SymptomLikely cause
z-index has no effectElement is position: static and not a flex or grid child
Huge z-index still behindAn ancestor forms a stacking context with a lower layer
Dropdown hidden under next sectionParent has transform, opacity or filter
Menu clipped at the edgeoverflow: hidden on an ancestor, not z-index at all

Updated

The 3D view below pulls a small page apart into its stacking layers so you can see which boxes are trapped inside which context. Change z-index values and toggle the property that creates a context on the parent. After it: every trigger per MDN, sticky headers with dropdowns, a token scale and a step-by-step debugging routine.

A z-index scale you can copy

:root {
  --z-base: 0; /* Normal content */
  --z-raised: 10; /* Cards lifted on hover, sticky table headers */
  --z-dropdown: 100; /* Menus, autocomplete lists */
  --z-sticky: 200; /* Sticky site header */
  --z-overlay: 300; /* Drawer backdrop */
  --z-modal: 400; /* Custom modals (native dialog uses the top layer instead) */
  --z-toast: 500; /* Notifications that must sit above everything */
}

Everything that creates a stacking context

Based on the MDN stacking context reference. Any one condition is enough.

Stacking context triggers
Property or stateCondition
Root elementhtml is always the root stacking context
position: absolute or relativeOnly with a z-index other than auto
position: fixed or stickyAlways, z-index not needed
Flex or grid childWith a z-index other than auto, even if position is static
container-type: size or inline-sizeAny element that is a size query container
opacity below 1Including opacity: 0.99
mix-blend-modeAny value other than normal
transform, scale, rotate, translateAny value other than none
filter or backdrop-filterAny value other than none
perspectiveAny value other than none
clip-pathAny value other than none
mask, mask-image, mask-borderAny value other than none
isolation: isolateMade for exactly this purpose, no visual side effects
will-changeNaming any property that would create a stacking context when set
containlayout, paint, or a shorthand that includes them (strict, content)
Top layerOpen modal dialog, popover, fullscreen element, and their ::backdrop

The trap and the fixes

z-index 9999 that still loses

The blue card is a context at z-index 1. Its tooltip has 9999 but is compared only with things inside the card, so the orange box at z-index 2 covers it.

<style>
  .card { position: relative; z-index: 1; background: #bfdbfe; padding: 12px; width: 180px }
  .tip { position: absolute; z-index: 9999; top: 40px; left: 60px; background: #1e3a8a; color: #fff; padding: 8px; width: 150px }
  .other { position: relative; z-index: 2; background: #fdba74; margin-top: -10px; margin-left: 100px; padding: 24px; width: 140px }
</style>
<div class="card">Card (z 1)<div class="tip">Tooltip (z 9999)</div></div>
<div class="other">Other box (z 2)</div>

isolation: isolate keeps a negative layer inside

Without isolate, the decoration with z-index: -1 would drop behind the section's background and disappear. With it, the section is its own context.

<style>
  .hero { isolation: isolate; position: relative; background: #f1f5f9; padding: 24px; overflow: hidden }
  .blob { position: absolute; z-index: -1; inset: 10px auto auto 10px; width: 120px; height: 80px; background: #a78bfa; border-radius: 50% }
</style>
<section class="hero"><div class="blob"></div><h2 style="margin:0">Text above the shape</h2></section>

Sticky header with a dropdown

The header is sticky, so it is already a context. Give it a z-index above the content and the dropdown inside inherits that layer. No huge numbers needed.

:root { --z-dropdown: 100; --z-sticky: 200; }
.site-header {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);   /* above every section below */
  background: #fff;           /* so content does not show through */
}
.site-header .menu {
  position: absolute;
  top: 100%;
  z-index: var(--z-dropdown); /* only competes inside the header */
}
/* Do not put transform or overflow: hidden on .site-header, or the menu gets clipped or trapped. */

How painting order works

Inside one stacking context the browser paints in a fixed order: the context's own background, then children with negative z-index, then normal block content, floats, inline content, positioned elements with z-index auto or 0 in source order, and finally positive z-index from low to high.

A child context is painted as a single unit at its parent's position in that list. Everything inside is sorted only against its siblings. That is the whole reason a 9999 inside a z-index: 1 card loses to a z-index: 2 neighbour.

A debugging routine

First confirm it is a stacking problem: if the element is cut off at a straight edge, look for overflow on an ancestor instead. Then walk up from the hidden element in DevTools, checking each ancestor's computed styles for the triggers listed on this page.

Chromium DevTools has a Layers panel and Edge has a 3D View that show composited layers visually. Once you find the ancestor that forms the context, either raise that ancestor, remove the trigger, or render the floating piece outside it.

How it works

  1. Set z-index values for the header, the card and the dropdown inside the card.
  2. Toggle a stacking-context trigger on the card and watch the dropdown get trapped.
  3. Turn on the 3D view to see the layers separated and grouped by context.
  4. Use isolation: isolate or move the dropdown out of the context to fix it.

Tips

Frequently asked questions

Why is z-index not working?

Either the element is not positioned (z-index needs position other than static, or a flex or grid parent), or it is trapped in a stacking context. The parent's position in its own context decides the result, and children cannot escape it, whatever number they carry.

What creates a stacking context?

The root element, positioned elements with a z-index, fixed and sticky elements, flex and grid children with a z-index, opacity below 1, transforms, filters, clip-path, masks, mix-blend-mode, perspective, isolation: isolate, some contain and will-change values, size containers and top layer elements.

What does isolation: isolate do?

It creates a new stacking context and does nothing else, with no visual change. Put it on a component root so negative z-index children stay behind the component's own background instead of falling behind the page, and so its internal layers never mix with outside ones.

How do I keep a dropdown above the next section?

Give the header or container holding the dropdown a higher z-index than the sections after it, both being positioned. If the container has a transform or opacity, remember that is its own context, so the z-index must go on that container, not on the dropdown.

Is a negative z-index safe?

It is valid, but the element goes behind its stacking context's background. Without a context on the parent, that can mean behind the page background and invisible. Add isolation: isolate on the parent to keep it inside.

Related: CSS position, CSS transforms, CSS variables, Modern CSS features, CSS specificity calculator.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap