CSS z-index is one of the most misunderstood properties in CSS. Developers increase it to 999 or even 9999 expecting an element to appear on top — and it still gets buried. The reason is almost always the same: stacking contexts. This guide explains how z-index actually works, what stacking contexts are, why they matter, and how to debug and fix layering problems without resorting to arbitrary large numbers.
What z-index does
z-index controls the stacking order of elements along the Z axis — the axis pointing toward and away from the viewer. Elements with a higher z-index appear in front of elements with a lower one.
.behind {
z-index: 1;
}
.in-front {
z-index: 2; /* appears on top of .behind */
}
However, z-index only works on positioned elements — elements with a position value of relative, absolute, fixed, or sticky. On elements with position: static (the default), z-index is completely ignored.
/* z-index has NO effect here */
.element {
position: static; /* default */
z-index: 100; /* ignored */
}
/* z-index WORKS here */
.element {
position: relative;
z-index: 100; /* applied */
}
Stacking Order Without z-index
Even without any z-index values set, the browser still has rules for which elements paint on top of which. The default painting order from bottom to top is:
- Background and borders of the root element
- Non-positioned elements, in source order (later in HTML = on top)
- Positioned elements with
z-index: autoorz-index: 0, in source order
This means that in a normal document flow, an element that appears later in the HTML will naturally paint on top of an earlier one — no z-index needed. You only need z-index when you need to override that natural order.
<div class="box-a">A</div>
<div class="box-b">B</div>
Stacking Contexts — The Root of All z-index Confusion
A stacking context is an isolated layer in which child elements are stacked relative to each other. Elements inside a stacking context are always stacked and painted as a group — they cannot escape it to sit above or below elements outside their stacking context, regardless of their z-index value.
This is why z-index: 9999 sometimes does nothing. If the element is inside a stacking context that itself sits below another element, no z-index value on the child can lift it out.
<div class="parent" style="position:relative; z-index:1;">
<div class="child" style="position:relative; z-index:9999;">
I am trapped inside parent's stacking context
</div>
</div>
<div class="other-element" style="position:relative; z-index:2;">
I will always be above .parent and everything inside it
</div>
Think of stacking contexts like a deck of cards within a box. You can reorder the cards inside the box however you like, but the whole box moves as a unit relative to other boxes.
What Creates a New Stacking Context
This is the list most developers are missing. A new stacking context is created by any element that has:
position: relative,absolute, orfixedwith az-indexvalue other thanautoposition: sticky(always creates one)opacityless than1transformother thannonefilterother thannonebackdrop-filterother thannonewill-changeset to any value that would create a stacking contextisolation: isolatemix-blend-modeother thannormalcontain: layout,paint, orstrict- The root element (
<html>) always creates one
The hidden traps on this list are opacity, transform, and filter. Adding transform: translateZ(0) as a performance hack, or setting opacity: 0.99 for a fade, silently creates a stacking context and can break your layering.
/* This silently creates a stacking context and may break z-index on children */
.parent {
transform: translateZ(0); /* GPU hack — creates stacking context */
filter: blur(0); /* same problem */
opacity: 0.99; /* same problem */
}
isolation: isolate — The Clean Solution
isolation: isolate explicitly creates a stacking context without any visual side effect. It is the cleanest way to contain the stacking behaviour of a component without adding arbitrary z-index values or visual changes.
/* Create a stacking context with no visual change */
.component {
isolation: isolate;
}
/* Now child z-index values are relative to .component,
not the root document */
.component .dropdown {
position: absolute;
z-index: 10; /* 10 relative to .component, not the page */
}
This is particularly useful when building reusable components. Each component gets its own isolated stacking context, so z-index values inside it never conflict with the rest of the page.
Practical z-index Scale
Avoid arbitrary values like z-index: 9999. Use a consistent, documented scale across your project. A common pattern is to define layers in increments:
/* Suggested z-index scale using CSS custom properties */
:root {
--z-below: -1;
--z-base: 0;
--z-raised: 10; /* cards, dropdowns */
--z-dropdown: 100; /* navigation menus */
--z-sticky: 200; /* sticky headers */
--z-overlay: 300; /* modal overlays */
--z-modal: 400; /* modal dialogs */
--z-toast: 500; /* notifications */
--z-tooltip: 600; /* tooltips */
}
.sticky-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
}
.modal-overlay {
position: fixed;
z-index: var(--z-overlay);
}
.modal-dialog {
position: fixed;
z-index: var(--z-modal);
}
Using a scale like this makes it immediately obvious what sits above what, and prevents the "add 1000 and hope" approach that leads to unmaintainable stylesheets.
Common Scenarios and Fixes
Dropdown menu appears behind another section
/* Problem: .hero has transform applied which creates a stacking context */
.hero {
transform: translateY(-20px); /* creates stacking context! */
}
.navbar {
position: sticky;
top: 0;
z-index: 100;
}
.navbar .dropdown {
position: absolute;
z-index: 200; /* has no effect outside navbar's stacking context */
}
/* Fix option 1: Remove transform from .hero, use margin instead */
.hero {
margin-top: -20px; /* no stacking context created */
}
/* Fix option 2: Give .hero a z-index lower than .navbar */
.hero {
transform: translateY(-20px);
z-index: 1; /* now .navbar at z-index:100 is clearly above */
}
Modal appears behind sticky header
/* Problem: sticky header creates its own stacking context */
.sticky-header {
position: sticky;
top: 0;
z-index: 200;
}
.modal-overlay {
position: fixed;
z-index: 9999; /* may still lose to sticky header */
}
/* Fix: ensure modal is not a child of any lower stacking context.
Render modals directly inside — not inside page sections. */
A positioned element is hidden behind a non-positioned sibling
/* Problem: later sibling in HTML paints on top by default */
.card {
position: relative;
/* no z-index — paints below .later-element by source order */
}
.later-element {
/* no position — but comes later in HTML */
}
/* Fix: add z-index to the positioned element */
.card {
position: relative;
z-index: 1;
}
How to Debug z-index Problems
When z-index is not working as expected, follow this checklist in order:
- Check position: Is the element positioned (
relative,absolute,fixed, orsticky)? If not,z-indexis ignored. - Find the stacking context: Inspect the element's ancestors in DevTools. Look for any parent with
opacity,transform,filter, or az-indexon a positioned element. - Compare at the right level:
z-indexvalues only compete within the same stacking context. Compare the stacking context ancestors of both elements, not the elements themselves. - Use isolation: Add
isolation: isolateto components to contain their internal stacking without side effects. - Move the element in the DOM: If a modal is inside a low-stacking-context container, move it to be a direct child of
<body>.
Frequently Asked Questions
Why does z-index: 9999 not work?
Almost always because the element is inside a stacking context that itself sits below another element. No matter how high the child's z-index is, it cannot escape its parent's stacking context. Find the stacking context ancestor — usually a parent with transform, opacity, filter, or a z-index — and fix the layering at that level instead.
Does z-index work without position?
No. z-index only applies to positioned elements — those with position set to relative, absolute, fixed, or sticky. On elements with the default position: static, the browser ignores z-index entirely.
What is a stacking context in CSS?
A stacking context is an isolated group of elements that are stacked and painted together as a unit. Elements inside a stacking context are always rendered together — their z-index values only compete with each other inside that context, never with elements outside it. The root <html> element is always the top-level stacking context.
Can z-index be negative?
Yes. A negative z-index (such as z-index: -1) places the element behind its parent and behind normal document flow. This is commonly used to place a pseudo-element behind its parent without hiding other content. The element must still be positioned for z-index: -1 to work.
Does transform always break z-index?
Not always — but it creates a new stacking context, which changes how z-index works for child elements. The child's z-index becomes relative to the transformed parent rather than the document root. This is often unexpected and causes layering bugs, especially when transform is added as a GPU performance optimisation.
What is the best practice for managing z-index across a project?
Define a named scale using CSS custom properties at the root level — for example --z-dropdown: 100, --z-modal: 400, --z-tooltip: 600. This gives every team member a shared vocabulary and prevents competing values from growing uncontrollably. Use isolation: isolate on self-contained components so their internal stacking never leaks out.