GAP·MAP

Stacking context & z-index

[ junior depth ]

Why a larger z-index can lose

Suppose a tooltip uses z-index: 9999 and still appears below a toast at z-index: 2. The numbers may belong to different stacking contexts. CSS compares stack levels inside one context. After the browser orders a context's contents, that context participates as one unit in its parent.

<article class="card">
  <div class="tooltip">Tooltip</div>
</article>
<div class="toast">Toast</div>
.card {
  position: relative;
  z-index: 1;
}

.tooltip {
  position: absolute;
  z-index: 9999;
}

.toast {
  position: fixed;
  z-index: 2;
}

The card and toast are compared in their shared parent context. The toast at 2 paints above the card at 1. The tooltip's 9999 only orders content inside the card, so it cannot jump above the toast.

Wrong "z-index is one page-wide number line. Keep increasing the tooltip until it wins."

Right "Find the contexts that contain the two elements. Compare the sibling contexts before comparing their descendants."

When z-index creates a context

For an ordinary box, an integer z-index works with a positioned element. position: relative is enough to make an element positioned without removing it from normal flow:

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

A relative or absolute element with z-index: auto does not create a stacking context for that reason alone. An integer, including 0, creates one. Fixed and sticky positioned boxes create stacking contexts even when their z-index is auto. Flex and grid items can also use a non-auto z-index without a position declaration.

Other CSS features create contexts too. Common examples are opacity below 1, a non-none transform, and isolation: isolate.

.faded { opacity: 0.9; }
.moved { transform: translateX(0); }
.widget { isolation: isolate; }

Each declaration above creates a local stacking context. Adding one to an ancestor can change a descendant overlay even when the overlay's own CSS stays untouched.

Stacking and positioning answer different questions

A containing block controls geometry, including the reference rectangle for offsets and percentage sizes. A stacking context controls painting order. They are separate concepts.

An absolutely positioned element usually uses the padding box of its nearest positioned ancestor as its containing block:

.card { position: relative; }
.tooltip {
  position: absolute;
  inset-block-start: 100%;
  inset-inline-start: 0;
}

Here .card provides the tooltip's containing block, even if the card keeps z-index: auto and creates no stacking context through positioning. Positioning can therefore change where an element lands without trapping its z-index in a new context.

[ middle depth ]

The stacking-context trigger list

The document root creates the root stacking context. MDN's current trigger list also includes these cases:

  • position: relative or absolute with z-index other than auto
  • position: fixed or sticky
  • a flex item or grid item with z-index other than auto
  • opacity below 1, or mix-blend-mode other than normal
  • non-none transform, scale, rotate, translate, filter, backdrop-filter, perspective, clip-path, mask, mask-image, or mask-border
  • isolation: isolate
  • contain: layout or contain: paint, including composite values such as strict and content
  • container-type: size or inline-size
  • will-change naming a property that would create a context at a non-initial value
  • an element in the top layer and its ::backdrop
  • a context-creating property animated with @keyframes when animation-fill-mode: forwards retains the created state

Memorizing every property is less useful than recognizing the families: positioned stacking, compositing and visual effects, containment, and browser-managed top-layer content. The full list matters during an ancestor walk because an innocent-looking wrapper can establish the boundary.

Wrong "Only position plus z-index creates a stacking context."

Right "Positioning is one trigger family. Opacity, transforms, containment, isolation, and several visual effects can create the same boundary."

How nested z-index values resolve

Within a context, an integer z-index gives a positioned box its stack level and creates a local context. Descendant contexts are then atomic in their parent. No descendant number is compared directly with a box outside its context.

.panel-a {
  position: relative;
  z-index: 3;
}

.panel-a__menu {
  position: absolute;
  z-index: 100;
}

.panel-b {
  position: relative;
  z-index: 4;
}

.panel-b paints above the whole of .panel-a. The menu's 100 only wins comparisons inside .panel-a. If the entire first panel should be above the second, change the sibling context:

.panel-a {
  position: relative;
  z-index: 5;
}

If only the menu should cross the boundary, it must participate outside that lower context. One structural version places the overlay beside the panels:

<section class="panel-a">...</section>
<section class="panel-b">...</section>
<div class="overlay-root">
  <div class="menu">...</div>
</div>
.overlay-root {
  position: relative;
  z-index: 10;
}

This changes which context contains the menu. A larger number on the original trapped descendant does not.

Containing blocks are a different ancestor search

For position: absolute, the usual containing block is the padding box of the nearest ancestor whose position is not static. For position: fixed, the viewport normally establishes the containing block in continuous media.

Several properties can override that fixed or absolute search. A nearest ancestor with a non-none transform, filter, backdrop filter, perspective, or individual transform can form the containing block. Layout or paint containment can do it too, as can relevant will-change values and content-visibility: auto. MDN notes browser inconsistencies for perspective and filter in this role.

.shell {
  transform: translateX(0);
}

.banner {
  position: fixed;
  inset: 0;
}

The shell's transform creates a stacking context and establishes the banner's containing block. The banner can be trapped in paint order and cover the shell's padding box instead of the viewport.

Opacity shows why the terms cannot be merged:

.shell {
  opacity: 0.99;
}

This creates a stacking context, but opacity does not appear in the containing-block trigger list. It can cage the banner's z-index without changing the rectangle used by fixed positioning.

[ senior depth ]

Paint order inside one stacking context

CSS 2.2 defines a detailed recursive painting algorithm. For the overlap bugs that appear in interviews, the useful back-to-front groups are:

  1. the context element's background and border
  2. positioned descendant contexts with negative z-index, from lower values upward
  3. in-flow non-positioned block descendants
  4. non-positioned floats
  5. in-flow inline content
  6. positioned descendants with z-index: auto and child contexts at z-index: 0, in tree order
  7. positive child contexts, from lower z-index upward

The specification has more detail for tables, line boxes, replaced elements, and outlines. Do not flatten those details into the list above when exact painting behavior depends on them.

Equal levels use tree order. A later sibling at the same stack level paints after an earlier sibling under the relevant painting step.

<div class="first">First</div>
<div class="second">Second</div>
.first,
.second {
  position: relative;
  z-index: 0;
}

Where these boxes overlap, .second is later in tree order and is painted later in the zero-level group.

Auto and zero can look equal until descendants overlap

For a positioned relative or absolute box, z-index: auto gives the generated box stack level 0 without creating a local stacking context through z-index. An integer z-index: 0 creates one. The two boxes can occupy the same painting group while treating descendants differently.

.wrapper {
  position: relative;
  z-index: auto;
}

A positioned descendant that creates a context can participate in the wrapper's current ancestor context because this wrapper did not establish a new one through z-index. Changing the declaration cages that descendant:

.wrapper {
  position: relative;
  z-index: 0;
}

Wrong "auto and 0 are interchangeable because both sit at stack level zero."

Right "They can paint in the same group, but integer zero creates a local stacking context and auto does not for a relative or absolute box."

Fixed and sticky boxes are the exception to the second half of that statement. CSS Positioned Layout Level 3 says they form stacking contexts even when z-index is auto.

Negative z-index stays inside its context

A negative child context paints after its context element's own background and border. It cannot pass through that context to get behind unrelated ancestors.

.card {
  position: relative;
  z-index: 0;
  background: white;
}

.card::before {
  content: "";
  position: absolute;
  inset: 8px;
  z-index: -1;
  background: black;
}

The pseudo-element belongs to the card's local context. Its negative level places it behind later content in that context, but the card's background is painted first and remains below it. The negative value does not send the pseudo-element behind the page.

Debug both trees before changing a number

An overlap bug can combine two ancestor relationships. The stacking-context chain answers which boxes can compete. The containing-block chain answers which rectangle controls offsets and percentage sizing.

Consider a fixed modal under a transformed shell:

.shell {
  transform: translateX(0);
}

.modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
}

The transform produces two effects documented separately: it creates a stacking context, and it establishes the containing block for fixed and absolute descendants. Removing it when it has no required effect addresses both boundaries:

.shell {
  transform: none;
}

When the transform is required, move the modal outside that subtree so its geometry and stack level resolve in the intended ancestor contexts:

<main class="shell">...</main>
<div class="overlay-root">
  <div class="modal">...</div>
</div>

For a stubborn case, write down both elements' nearest stacking contexts, compare those contexts in their shared parent, then repeat outward. Separately identify the positioned element's containing block. This keeps a paint-order diagnosis from being confused with a geometry bug.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more CSS

was this useful?