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-az-index. Notice it can go above or below.card-b. This is a normal situation: both cards share the same stacking context.Card ACard BRight: we’ll later create separate stacking contexts and show how the “universe” rule traps
z-index.Card ACard 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-indexitems, normal flow, positioned items, positivez-indexitems. -
If two things have the same stacking level (same
z-indexgroup), 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;
}
NEWPainting order demo
Try
z-index: auto,1, and-1on 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;
}
Goal: make the blue note appear above the overlay. First snippet sets
z-indexbut without positioning. Second snippet addsposition: 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 topThis header is a separate layer. The dropdown below will try to overlap it.
Content Watch what happens when the sidebar uses
transform. (Spoiler:transformcreates 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-indexnot equal to the default ofauto(for exampleposition: relative; z-index: 1;) -
opacityless than1(even0.999creates one) -
transformnot equal tonone -
filternot equal tonone -
isolation: isolate -
will-changefor properties liketransform(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 boxThis box has a higher
z-indexthan the blue box.Context makerThe overlay lives inside me with
z-index: 9999. Try different snippets: opacity, transform, filter, isolation.
Stacking Contexts vs z-index Levels: The Two-Step Mental Model
Here’s the mental model that keeps you sane:
- Step 1: figure out which stacking context each element belongs to.
-
Step 2: compare
z-indexvalues 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 dropdownThe 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.Dropdown menu
- item 1
- item 2
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:
-
Confirm
z-indexapplies: is the element positioned (or otherwise a stacking item)? -
Find the nearest stacking context ancestor: look up the DOM for
transform,opacity,filter,isolation,contain, or positioned +z-index. -
Compare contexts, not just numbers: if the “enemy” element is in a different stacking context,
your child’s
z-indexcan’t beat it directly. - 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-indexis only compared inside that universe. -
z-indexneeds participation. Typically: a positioned element (positionnotstatic) or a flex/grid child. -
Common context creators:
transform,opacity < 1,filter,isolation: isolate,contain: paint, positioned + non-autoz-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-indexis equal (orauto). -
Negative
z-indexcan 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 contentIn 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 higherz-index.
Page content continues here.Modal
I live in a dedicated top layer. This is the “overlay root” pattern.
Common Fixes (and Anti-Fixes)
-
Fix: Add positioning before using
z-index(oftenposition: relative). -
Fix: Remove accidental stacking context creators on ancestors (commonly
transform). -
Fix: Control stacking at the context level (set
z-indexon the parent layers, not only the child). - Fix: Move overlays (dropdowns/modals/tooltips) to a dedicated top-level container.
-
Anti-fix: Randomly increasing
z-indexnumbers 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.
