CSS z-index and Stacking Context

When elements overlap in a web page — a dropdown menu covering text below it, a modal appearing on top of a page, a tooltip peeking above a card — something has to decide which one appears on top. The z-index property lets you control that ordering along CSS’s third, invisible axis: the z-axis, running straight out of the screen toward the viewer. But z-index doesn’t work in isolation; it only makes sense once you understand stacking contexts, the self-contained groups the browser sorts z-index values within. Get the mental model right and layering bugs — menus hidden behind other elements, modals that refuse to come to the front — become easy to diagnose.

Overview: How Stacking Works

Every element in a web page occupies a hierarchy along three axes: its horizontal position, its vertical position, and — less visibly — its position along the z-axis, the axis that points out of the screen toward you. The z-index property lets you control that third axis directly, but only under specific conditions, and only relative to a specific set of sibling elements. Understanding those conditions is the entire skill.

First, z-index only has an effect on elements that participate in positioning: elements whose position is relative, absolute, fixed, or sticky, or elements that are flex or grid items. Give z-index to a statically positioned element (the default) and the browser ignores it completely — the declaration is valid CSS, it just has no visual effect.

Second, and more subtly, z-index values are never compared globally across the whole page. They are compared only within a stacking context — a self-contained layer of the page that has its own internal front-to-back ordering. The root <html> element always creates the top-level stacking context, but many other things create new, nested stacking contexts too: a positioned element with an explicit (non-auto) z-index, any element with opacity less than 1, a transform, a filter, a perspective, a clip-path, a mix-blend-mode other than normal, or the explicit isolation: isolate declaration.

Once an element creates a new stacking context, everything inside it is painted as a single unit relative to its siblings. Its descendants can have z-index values in the thousands, but those values only settle disputes inside that context. From the outside, the entire subtree behaves as if it had just one stacking position — the one belonging to the element that created the context. This is the single most common source of z-index confusion: a deeply nested element with a huge z-index still loses to a sibling subtree if the ancestor that created its context is stacked lower than that sibling.

Syntax

.element {
  z-index: 10;
}

The general form is a property-value pair inside a rule for a positioned element:

Value Meaning
auto The default. The element does not itself create a new stacking context from z-index (though other properties like transform might still force one); it is painted in the order it appears relative to its siblings.
Positive integer (e.g. 1, 10, 500) Stacks the element above sibling elements with a lower (or auto) value within the same stacking context.
Negative integer (e.g. -1) Stacks the element behind its positioned ancestor’s background and behind normal-flow siblings, but still inside the same stacking context.
0 A specific stacking level — a positioned element with z-index: 0 also creates a new stacking context, exactly like any other integer value.

Only integers are valid — decimals such as 1.5 are not accepted, and there is no unit (never write z-index: 10px).

Examples

Example 1: Basic overlap with position and z-index

Two absolutely positioned boxes inside a relatively positioned container, offset so they overlap:

.stack {
  position: relative;
  width: 300px;
  height: 200px;
}

.stack__back {
  position: absolute;
  top: 20px;
  left: 20px;
  width: 180px;
  height: 140px;
  background-color: #4f46e5;
  z-index: 1;
}

.stack__front {
  position: absolute;
  top: 60px;
  left: 80px;
  width: 180px;
  height: 140px;
  background-color: #f59e0b;
  z-index: 2;
}

Result: Applied to a container <div class=’stack’> holding two child <div>s with classes stack__back and stack__front, the page shows two overlapping rectangles offset diagonally. In the region where they overlap, the orange stack__front rectangle is drawn completely on top of the indigo stack__back rectangle, because 2 is greater than 1 within the same stacking context (the .stack container itself does not create a new one).

Notice that both boxes needed position: absolute before z-index could do anything at all — without it, the declarations would simply be dropped during the paint step, even though they parse and apply as perfectly normal CSS.

Example 2: A transform silently creates a trapping stacking context

.card {
  position: relative;
  transform: translateZ(0);
  z-index: 1;
}

.card__badge {
  position: absolute;
  top: -10px;
  right: -10px;
  z-index: 999;
  background-color: crimson;
  color: #fff;
  padding: 4px 8px;
  border-radius: 999px;
}

.overlay {
  position: fixed;
  inset: 0;
  z-index: 2;
  background-color: rgba(0, 0, 0, 0.6);
}

Result: Assume <div class=’card’> (containing a <span class=’card__badge’>) and a separate <div class=’overlay’> are both direct children of <body>, with the overlay appearing later in the markup. Despite the badge’s z-index of 999, the semi-transparent black overlay still completely covers the badge. That happens because transform forces .card to create its own stacking context, stamped into the page at z-index 1. The badge’s 999 only wins arguments inside .card; from the page’s point of view .card as a whole is stacked at 1, which loses to the overlay’s 2.

This is the classic z-index trap: a large number deep in the tree cannot rescue an element whose stacking-context-creating ancestor is positioned lower than a sibling context.

Example 3: A deliberate z-index scale for a real interface

:root {
  --z-navbar: 100;
  --z-dropdown: 200;
  --z-modal-backdrop: 300;
  --z-modal: 310;
  --z-toast: 400;
}

.navbar {
  position: sticky;
  top: 0;
  z-index: var(--z-navbar);
}

.navbar__dropdown {
  position: absolute;
  z-index: var(--z-dropdown);
}

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal-backdrop);
  background-color: rgba(0, 0, 0, 0.5);
}

.modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: var(--z-modal);
  background-color: #fff;
  padding: 24px;
  border-radius: 8px;
}

.toast {
  position: fixed;
  bottom: 16px;
  right: 16px;
  z-index: var(--z-toast);
}

Result: With this scale in place, a sticky navbar sits above ordinary page content; its dropdown menu, opened on click, appears above the navbar itself; when a modal opens, a dark backdrop covers the entire page including the navbar and dropdown, the modal dialog floats above that backdrop, and a toast notification triggered afterward appears above everything, including the modal. Every layer has clear headroom (100-point gaps) so a future addition — say, a second dropdown — can be inserted without renumbering the rest.

Using custom properties for the scale means the numbers are documented once and reused everywhere, instead of scattered magic numbers that are easy to duplicate or contradict.

Under the Hood: The Painting Order

Inside any single stacking context, the browser paints its contents in seven layers, from back to front, per the CSS specification:

  • The stacking context root’s own background and borders.
  • Child stacking contexts and positioned descendants with negative z-index, most negative first.
  • Non-positioned, non-floated block-level descendants, in document (source) order.
  • Floated descendants.
  • Inline-level descendants (text, inline elements).
  • Positioned descendants (and child stacking contexts) with z-index: auto or z-index: 0, in document order.
  • Positioned descendants (and child stacking contexts) with a positive z-index, lowest to highest, breaking ties by document order.

Two things fall out of this list that trip people up constantly. First, plain inline and normal-flow content (layers three through five) is always painted above negative-z-index elements but below zero-or-positive positioned elements — so a negatively indexed box can end up hidden behind a page’s plain background text. Second, when two sibling elements share the exact same z-index (including the common case of both being auto), the tie is broken by source order: whichever one appears later in the HTML paints on top. This is why reordering markup, even with identical CSS, can change which element wins an overlap.

Walk Example 2 manually and the trap becomes obvious: the browser resolves the root stacking context and sees two children — the .card subtree and the .overlay element. .card sits at z-index 1 (because transform forced a context and it carries a numeric z-index), .overlay sits at z-index 2. Per the rules, 2 paints after 1, so the entire .card subtree — badge included — is fully settled and painted before .overlay even begins, regardless of what happens to z-index values inside .card.

Common Mistakes

Mistake 1: Setting z-index without positioning the element

.tooltip {
  z-index: 100;
  background-color: #222;
  color: #fff;
  padding: 8px;
}

This is valid CSS and it parses fine, but it does nothing: with the default position: static, the rendering engine doesn’t even consider the element for z-index purposes. The tooltip stays exactly where normal document flow would put it, stacked in source order like any other static element. The fix is to give it an explicit position:

.tooltip {
  position: absolute;
  z-index: 100;
  background-color: #222;
  color: #fff;
  padding: 8px;
}

Now the element is a positioned element, participates in the surrounding stacking context, and the z-index takes effect.

Mistake 2: Nesting a full-screen overlay inside an element that creates its own stacking context

.gallery {
  position: relative;
  opacity: 0.99;
}

.gallery__lightbox {
  position: fixed;
  inset: 0;
  z-index: 9999;
}

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

The intent here is for .gallery__lightbox to cover the entire viewport, including the header, whenever it opens. But .gallery has opacity: 0.99 (often added, sometimes accidentally, to work around an unrelated rendering issue), which creates a new stacking context for the whole gallery. Because .gallery itself has no explicit z-index, it participates in the page’s root context at the default level, and .site-header, with an explicit z-index of 10, ends up painted above the entire gallery subtree — lightbox included — no matter how high z-index: 9999 goes inside it. The fix is to stop the fixed overlay from being trapped: remove the stacking-context-creating property from the ancestor, or, more robustly, move the lightbox markup so it isn’t a descendant of an element that creates a stacking context at all — placing it as a direct child near the end of <body> instead:

.gallery {
  position: relative;
}

.gallery__lightbox {
  position: fixed;
  inset: 0;
  z-index: 9999;
}

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

With the opacity removed from .gallery (and the lightbox rendered outside of it in the markup, in a real project), z-index: 9999 is now compared directly against .site-header’s 10 in the same context, and wins as expected.

Best Practices

  • Never set z-index on a statically positioned element expecting it to work — pair it with position: relative, absolute, fixed, or sticky first.
  • Keep a small, documented scale of z-index values (via CSS custom properties) for an entire app — for example steps of 100 for navbar, dropdown, modal, and toast — instead of scattering arbitrary numbers like 9999 across stylesheets.
  • Avoid stacking-context wars: if you find yourself raising a z-index to beat another one repeatedly, the real problem is usually an unwanted stacking context created by a transform, opacity, or filter somewhere in an ancestor, not the numbers themselves.
  • Use isolation: isolate when you want to intentionally create a fresh stacking context for a component, so its internal z-index values can never leak out or clash with the rest of the page, without also adding an unwanted visual side effect like blur or transparency.
  • Render overlays, modals, and lightboxes as siblings near the end of <body> rather than deeply nested inside components, so they are never accidentally trapped inside an ancestor’s stacking context.
  • Inspect stacking contexts with browser devtools instead of guessing from numbers alone — both Chrome and Firefox can highlight which elements create a stacking context in their inspector panels.

Practice Exercises

  • Build three overlapping <div>s, all positioned absolute inside one relatively positioned container, with staggered offsets. Give them z-index values of 5, 1, and 3, and predict — then check — the paint order from back to front before writing any HTML.
  • Take the z-index trap example from this lesson (a badge inside a transformed card, and a sibling overlay) and, without changing any z-index number, make the badge visible above the overlay by editing only the properties that create stacking contexts.
  • Design a z-index scale using custom properties for a page that has a sticky header, a sidebar with a dropdown, a modal, and toast notifications. Decide the order they should stack in, and write the custom properties along with the rules that consume them.

Summary

  • z-index only affects elements that are positioned (relative, absolute, fixed, sticky) or that are flex/grid items — on a statically positioned element it has no effect at all.
  • z-index values are compared only within the same stacking context, never globally across the whole page.
  • Many properties besides an explicit z-index create new stacking contexts, including opacity below 1, transform, filter, perspective, clip-path, a non-normal mix-blend-mode, and isolation: isolate.
  • Once nested inside a stacking context, no descendant z-index value, however large, can escape that context to beat an unrelated sibling context.
  • Within a context, painting order runs: background, negative z-index, normal-flow block content, floats, inline content, z-index auto/0, then positive z-index ascending; ties break by source order.
  • Prefer a small, documented z-index scale and keep overlays out of ancestors that accidentally create stacking contexts, rather than escalating arbitrary numbers.