CSS Stacking Context

If z-index has ever made you whisper “why are you like this?”, welcome. Stacking contexts are the missing chapter that makes z-index stop feeling haunted. In this tutorial we’ll build the mental model (and a bunch of interactive demos) so you can predict layering instead of trial-and-erroring your way into despair.

CSS Stacking Context Explained

Think of the browser as drawing your page in layers (like transparent sheets). A stacking context is a self-contained layering universe. Inside that universe, children can fight with z-index all they want, but they can’t escape to layer above things in a different universe.

The important consequence is: a child’s z-index is only compared against its siblings inside the same stacking context. If your element is “stuck underneath” something outside its context, increasing z-index might do absolutely nothing.

.card-a {
  z-index: 10;
}
  
.card-b {
  z-index: 10;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.stage {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.panel {
  border: 3px solid #111;
  border-radius: 14px;
  padding: 16px;
  background: #fafafa;
}

.hint {
  margin: 0 0 10px 0;
  font-size: 14px;
  opacity: 0.85;
}

.stack {
  position: relative;
  height: 220px;
  border: 2px dashed #111;
  border-radius: 14px;
  padding: 10px;
  overflow: hidden;
}

.card {
  position: absolute;
  width: 72%;
  height: 130px;
  border: 3px solid #111;
  border-radius: 14px;
  display: grid;
  place-items: center;
  font-weight: 700;
  background: white;
}

.card-a {
  left: 14px;
  top: 22px;
  background: #fff4d6;
  z-index: 1;
}

.card-b {
  left: 52px;
  top: 62px;
  background: #dff7ff;
  z-index: 2;
}
  

Left: change .card-a z-index. Notice it can go above or below .card-b. This is a normal situation: both cards share the same stacking context.

Card A
Card B

Right: we’ll later create separate stacking contexts and show how the “universe” rule traps z-index.

Card A
Card B

The Default Painting Order Inside a Stacking Context

Inside a single stacking context, the browser paints layers in a predictable order. You don’t need to memorize every micro-rule, but you do need the big picture:

  • Background and border of the stacking context element are painted first.
  • Then children are painted in groups: negative z-index items, normal flow, positioned items, positive z-index items.
  • If two things have the same stacking level (same z-index group), the one that appears later in the HTML usually paints on top.

Translation: DOM order matters when z-index is equal (or not set), and negative z-index can tuck things behind siblings in the same context.

.badge {
  z-index: auto;
}
  
.badge {
  z-index: 1;
}
  
.badge {
  z-index: -1;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.demo {
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.card {
  position: relative;
  border: 3px solid #111;
  border-radius: 16px;
  overflow: hidden;
  max-width: 520px;
}

.card img {
  display: block;
  width: 100%;
  height: 220px;
  object-fit: cover;
}

.content {
  padding: 14px 14px 18px 14px;
  background: white;
}

.title {
  margin: 0 0 6px 0;
  font-size: 18px;
}

.text {
  margin: 0;
  opacity: 0.85;
  font-size: 14px;
}

.badge {
  position: absolute;
  top: 12px;
  left: 12px;
  border: 3px solid #111;
  background: #fff4d6;
  border-radius: 999px;
  padding: 6px 10px;
  font-weight: 800;
}
  
The Complete CSS Stacking Context Interactive Tutorial
NEW

Painting order demo

Try z-index: auto, 1, and -1 on the badge. Negative values can hide behind things inside the same stacking context.

z-index Basics: When It Works (and When It Doesn’t)

The most common gotcha: z-index only affects elements that participate in stacking, which usually means the element is positioned (like position: relative, absolute, fixed, or sticky) or is otherwise treated as a stacking item (we’ll cover the “otherwise” soon).

Important to note that position: static (the default) elements don’t normally participate in stacking (i.e. their z-index is ignored), unless they are flex or grid items (that is to say, their parent has display: flex or display: grid), in which case they do participate in stacking and their z-index is applied.

If you set z-index on a plain old static element, the browser may shrug and ignore you.

.note {
  z-index: 10;
}
  
.note {
  position: relative;
  z-index: 10;
}
  
.note {
  position: relative;
  z-index: -1;
}
  
*, ::before, ::after {
  box-sizing: border-box;
}
.wrap {
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  max-width: 680px;
}
.box {
  border: 3px solid #111;
  border-radius: 14px;
  padding: 14px;
  background: #f8f8f8;
  position: relative;
}
.note {
  border: 3px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #dff7ff;
  max-width: 410px;
  transform: translateY(-100px);
}
.overlay {
  position: absolute;
  right: 12px;
  top: 12px;
  width: 240px;
  height: 120px;
  border: 3px solid #111;
  border-radius: 12px;
  background: #fff4d6;
  display: grid;
  place-items: center;
  font-weight: 800;
  z-index: 1;
}
  
Overlay

Goal: make the blue note appear above the overlay. First snippet sets z-index but without positioning. Second snippet adds position: relative.

I am the note. I want to be on top.

The Core Rule: A Child Can’t Escape Its Parent Stacking Context

This is the part that fixes 80% of real-world “z-index not working” bugs:

If an ancestor creates a stacking context, the child is trapped inside it. Even z-index: 999999 can’t jump above an element that lives in a different stacking context painted on top.

So what’s the trick? You usually need to either:

  • Move the element to a higher place in the DOM (so it’s in a different, higher stacking context), or
  • Stop the ancestor from creating a stacking context (remove the property that creates it), or
  • Create a deliberate “top layer” area (like a modal root) and render your overlay there.
.header {
  z-index: 2;
}
.sidebar {
  transform: translateZ(0);
}
.dropdown {
  z-index: 9999;
}
  
.header {
  z-index: 2;
}
.sidebar {
  transform: none;
}
.dropdown {
  z-index: 9999;
}
  
.header {
  z-index: 2;
}
.sidebar {
  z-index: 1;
}
.dropdown {
  z-index: 9999;
}
  
.header {
  z-index: 2;
}
.sidebar {
  transform: translateZ(0);
  z-index: 3;
}
.dropdown {
  z-index: 9999;
}
 
*,
::before,
::after {
  box-sizing: border-box;
}

.page {
  font-family: ui-sans-serif, system-ui, sans-serif;
  padding: 18px;
  display: grid;
  gap: 14px;
}

.header {
  position: relative;
  border: 3px solid #111;
  border-radius: 14px;
  padding: 14px;
  background: #fff4d6;
}

.main {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 14px;
}

.sidebar {
  position: relative;
  border: 3px solid #111;
  border-radius: 14px;
  padding: 12px;
  background: #dff7ff;
}

.content {
  border: 3px solid #111;
  border-radius: 14px;
  padding: 12px;
  background: #fafafa;
  min-height: 260px;
  position: relative;
}

.dropdown {
  position: absolute;
  top: -46px;
  left: 12px;
  width: 320px;
  border: 3px solid #111;
  border-radius: 14px;
  background: white;
  padding: 10px;
}

.pill {
  display: inline-block;
  border: 2px solid #111;
  border-radius: 999px;
  padding: 6px 10px;
  background: #f2f2f2;
  font-weight: 700;
  margin-bottom: 10px;
}
  
Header Often on top

This header is a separate layer. The dropdown below will try to overlap it.

Content

Watch what happens when the sidebar uses transform. (Spoiler: transform creates a stacking context.)

What just happened?

In the first snippet, transform: translateZ(0) on .sidebar creates a stacking context. That means the dropdown’s gigantic z-index is only competing inside the sidebar universe. The header is outside that universe and can still paint above it.

In the second snippet we remove the transform, which removes that stacking context, and now the dropdown can overlap the header (depending on the header’s own stacking rules).

In the third snippet we give the header a z-index of 2 and the sidebar a lower z-index of 1. Again, the dropdown can't escape its parent (the .sidebar) stacking context.

In the fourth snippet we keep the transform but explicitly manage layering by giving the sidebar and header controlled z-index values.

Common Things That Create a Stacking Context

You don’t need the entire spec memorized, but you do want to recognize the usual suspects. Here are some common stacking context creators you’ll bump into constantly:

  • Positioned element with z-index not equal to the default of auto (for example position: relative; z-index: 1;)
  • opacity less than 1 (even 0.999 creates one)
  • transform not equal to none
  • filter not equal to none
  • isolation: isolate
  • will-change for properties like transform (often behaves like it creates one)
  • contain: paint (and other containment modes that isolate painting)

Let’s make these “feel real” with a playground where we swap the context creator and watch how it traps an overlay.

.context-maker {
  opacity: 0.95;
}
  
.context-maker {
  opacity: 1;
}
  
.context-maker {
  transform: translateY(0);
}
  
.context-maker {
  transform: none;
}
  
.context-maker {
  filter: blur(0);
}
  
.context-maker {
  filter: none;
}
  
.context-maker {
  isolation: isolate;
}
  
.context-maker {
  isolation: auto;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.demo {
  font-family: ui-sans-serif, system-ui, sans-serif;
  padding: 18px;
  display: grid;
  gap: 14px;
  max-width: 820px;
}

.row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

.box {
  border: 3px solid #111;
  border-radius: 14px;
  padding: 14px;
  background: #fafafa;
  position: relative;
  min-height: 220px;
}

.context-maker {
  position: relative;
  border: 3px solid #111;
  border-radius: 14px;
  padding: 12px;
  background: #dff7ff;
}

.top-layer {
  position: relative;
  border: 3px solid #111;
  border-radius: 14px;
  padding: 12px;
  background: #fff4d6;
  z-index: 2;
}

.overlay {
  position: absolute;
  right: 10px;
  top: 54px;
  width: 320px;
  border: 3px solid #111;
  border-radius: 14px;
  padding: 10px;
  background: white;
  z-index: 9999;
}

.small {
  margin: 8px 0 0 0;
  font-size: 13px;
  opacity: 0.85;
}
  
Top layer box

This box has a higher z-index than the blue box.

Context maker

The overlay lives inside me with z-index: 9999. Try different snippets: opacity, transform, filter, isolation.

Overlay: “I want to overlap the yellow box.”

Stacking Contexts vs z-index Levels: The Two-Step Mental Model

Here’s the mental model that keeps you sane:

  1. Step 1: figure out which stacking context each element belongs to.
  2. Step 2: compare z-index values only among elements in the same context.

If two elements are in different stacking contexts, their relative order is decided by how those contexts are stacked in their parent context. That’s why you can crank a child to z-index: 999999 and still lose.

A Super Common Bug: Dropdown Under a Header

This is the classic: a header is sticky or fixed, and your dropdown inside a sidebar or card refuses to appear on top. Often the header is in a higher stacking context, and your dropdown is trapped in a lower one.

Let’s make a sticky header and a dropdown, then “accidentally” create a stacking context on the dropdown’s container.

.header {
  position: sticky;
  top: 0;
  z-index: 5;
}

.card {
transform: translateY(0);
} 
.header {
  position: sticky;
  top: 0;
  z-index: 5;
}

.card {
  transform: none;
}
  
.header {
  position: sticky;
  top: 0;
  z-index: 5;
}

.card {
  transform: translateY(0);
  z-index: 6;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.page {
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.header {
  border-bottom: 3px solid #111;
  background: #fff4d6;
  padding: 14px 18px;
}

.body {
  padding: 18px;
  display: grid;
  gap: 16px;
}

.card {
  border: 3px solid #111;
  border-radius: 16px;
  padding: 14px;
  background: #dff7ff;
  position: relative;
  max-width: 720px;
}

.dropdown {
  position: absolute;
  top: -58px;
  left: 234px;
  width: 190px;
  border: 3px solid #111;
  border-radius: 14px;
  background: white;
  padding: 10px;
  z-index: 9999;
}

.filler {
  border: 3px solid #111;
  border-radius: 16px;
  padding: 14px;
  background: #fafafa;
  max-width: 720px;
  min-height: 280px;
}
  
Sticky Header Scroll area simulation (no actual scroll needed to see the layering).
Card with dropdown

The dropdown wants to overlap the header. In snippet 1, the card uses transform (stacking context). Snippet 2 removes it. Snippet 3 keeps it but raises the card above the header.

Content below

Snippet 3 shows how increasing the z-index on the element creating the stacking context (here, the .card) can allow the dropdown to escape above the header, even if the dropdown itself is still limited to the card’s stacking context.

If you scroll a bit with snippet 3 active, you will notice that creates a new issue, the card itself is now above the header. Snippet 2 (removing the stacking context) is a better solution when possible.

Debugging Stacking Contexts: A Practical Workflow

When layering goes wrong, don’t guess. Use this checklist:

  1. Confirm z-index applies: is the element positioned (or otherwise a stacking item)?
  2. Find the nearest stacking context ancestor: look up the DOM for transform, opacity, filter, isolation, contain, or positioned + z-index.
  3. Compare contexts, not just numbers: if the “enemy” element is in a different stacking context, your child’s z-index can’t beat it directly.
  4. Fix the right layer: raise/lower the stacking context containers, or move the overlay to a higher root.

A quick trick: if adding transform: translateZ(0) “randomly” breaks your overlay later, it’s not random. It created a stacking context.

Stacking Context Cheat Sheet

  • Stacking context = mini universe. Child z-index is only compared inside that universe.
  • z-index needs participation. Typically: a positioned element (position not static) or a flex/grid child.
  • Common context creators: transform, opacity < 1, filter, isolation: isolate, contain: paint, positioned + non-auto z-index.
  • If “999999” doesn’t work, you’re in the wrong context. Raise the context container, remove the context creator, or move the overlay.
  • DOM order matters when z-index is equal (or auto).
  • Negative z-index can hide behind siblings in the same context (and sometimes behind the context’s background).
  • When building UI overlays: consider a dedicated “overlay root” near the end of <body>. (Many frameworks call this a portal.)

Real-World Pattern: Build a Modal That Always Wins

A reliable approach is to have a top-level modal container (near the end of the document) with a deliberately high stacking order, instead of nesting modals inside random cards/sidebars. This avoids the “trapped inside a stacking context” problem.

In this demo, we’ll simulate the idea by putting a modal overlay as a sibling of the app content, and giving it a higher stacking order.

.app {
  position: relative;
  z-index: 1;
}

.modal-layer {
position: absolute;
inset: 0;
z-index: 100;
} 
.app {
  position: relative;
  z-index: 200;
}

.modal-layer {
  position: absolute;
  inset: 0;
  z-index: 100;
}
  
.app {
  position: relative;
  z-index: 1;
  transform: translateY(0);
}

.modal-layer {
  position: absolute;
  inset: 0;
  z-index: 100;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.page {
  font-family: ui-sans-serif, system-ui, sans-serif;
  padding: 18px;
}

.app {
  border: 3px solid #111;
  border-radius: 16px;
  overflow: hidden;
}

.hero {
  padding: 16px;
  background: #dff7ff;
  border-bottom: 3px solid #111;
}

.hero img {
  width: 100%;
  height: 190px;
  object-fit: cover;
  display: block;
  border: 3px solid #111;
  border-radius: 14px;
  margin-top: 10px;
}

.body {
  padding: 16px;
  background: #fafafa;
  min-height: 160px;
}

.modal-layer {
  display: grid;
  place-items: center;
  background: rgba(0, 0, 0, 0.35);
}

.modal {
  width: min(560px, 92vw);
  border: 3px solid #111;
  border-radius: 16px;
  background: white;
  padding: 14px;
}

.modal h3 {
  margin: 0 0 8px 0;
  font-size: 18px;
}

.modal p {
  margin: 0;
  opacity: 0.85;
}
  
App content

In snippet 1, modal is above the app. In snippet 2, the app is above the modal (bad). In snippet 3, the app creates a stacking context via transform, but the modal still wins because it’s fixed and higher z-index.

The Complete CSS Stacking Context Interactive Tutorial
Page content continues here.

Common Fixes (and Anti-Fixes)

  • Fix: Add positioning before using z-index (often position: relative).
  • Fix: Remove accidental stacking context creators on ancestors (commonly transform).
  • Fix: Control stacking at the context level (set z-index on the parent layers, not only the child).
  • Fix: Move overlays (dropdowns/modals/tooltips) to a dedicated top-level container.
  • Anti-fix: Randomly increasing z-index numbers forever. If contexts differ, bigger numbers do nothing.

Wrap-Up

If you remember only one thing: stacking contexts decide the battle arena, and z-index decides the winners inside that arena.

Next time something won’t layer correctly, don’t reach for z-index: 9999999. Find the stacking context that’s limiting you, and fix the context relationship instead.

Learn even more about z-index in the CSS Z-Index Interactive Tutorial.