Skip to content

Stacking contexts

Browser as a runtime · part 2 of 6. Previous: CSS cascade.

Part 1 decided what color a box gets. This part is about who paints on top. Those are different machines. z-index is not a global high-score.

Card A has z-index: 9999. Card B has z-index: 1. A is sitting inside a parent wrapper. B is a sibling of that wrapper.

A should win, right? Play it. Then click the button.

A is 9999. B is 1. Click the button.

You barely changed anything. The parent got opacity: 0.99 — still looks solid. A’s number did not change. B’s number did not change. And yet B is on top.

Sit with that for a second. Why would a parent’s opacity, of all things, demote a child that is 9999?

z-index only ranks boxes that share the same stacking context. Think of it as a private leaderboard, not a website-wide ranking.

A stacking context is a sealed box. Numbers inside the box only compete with each other. The box as a whole gets one rank in the parent’s leaderboard. A child cannot climb out, no matter how big the number.

Analogy: a school, not one giant classroom.

  • Each classroom has its own seating chart (z-index among classmates).
  • The school ranks classrooms, not individual students.
  • The loudest kid in room 3 does not get a better seat than someone in room 1. Their 9999 never leaves the room.

When a parent becomes a stacking context, it is that classroom door clicking shut.

z-index compares inside a stacking context: 9999 inside a parent at auto still loses to a sibling at 1

The parent started as position: absolute with z-index: auto. That combination does not create a stacking context. The classroom door is open. A’s 9999 and B’s 1 sit in the same ranking. 9999 wins. You saw that first.

Then we set opacity: 0.99 on the parent. Any opacity less than 1 creates a stacking context. The door shuts.

Now the paint order is:

  1. Rank the parent as a whole against B. The parent is still z-index: auto (treated like 0 when it is a stacking context). B is z-index: 1. 1 beats auto.
  2. Inside the parent, A’s 9999 still wins — but only against siblings inside that parent. There are none that matter. A never gets a meeting with B.

That is why inspecting the losing child is the wrong move. Inspect the parent. If the parent sealed the box, raise its z-index (or stop creating the context). Inflating the child’s number does nothing.

Opacity is the famous one because 0.99 looks identical to 1. These do the same stacking trick. Several of them can look like “no visual change” too.

Each one below is a real CSS job. Creating a stacking context is a side effect. The second demo lets you apply each trigger to the same parent and watch A lose again.

Same layout. Pick a trigger. Watch who wins.

What it is: how see-through the element is. 1 is solid. 0 is invisible.

Why it traps: the browser composites that subtree as one layer so transparency can blend correctly. That layer is a stacking context.

.parent { opacity: 0.99; } /* looks solid, seals the box */

What it is: move, rotate, scale, or skew — translate, rotate, scale, matrix, and friends.

Why it traps: even translateX(0) or scale(1) (no visible change) promotes the element to its own layer. Animating a slide-in header is a classic: the header’s ancestor gets transform, and a modal z-index: 9999 suddenly sits under it.

.header-wrap { transform: translateY(0); } /* animation leftover, still seals */

What it is: visual effects on the element’s pixels — blur(), brightness(), drop-shadow(), grayscale(), and so on.

Why it traps: filters run on a snapshot of the whole subtree, so that subtree becomes one unit. A hover filter on a card can bury the next card’s dropdown.

.card:hover { filter: brightness(1.05); } /* slight glow, new stacking context */

What it is: “start a fresh blending group.” Mix-blend-mode and background-blend-mode stay inside this element instead of blending with whatever is behind it on the page.

Why it traps: isolation is literally “make a new stacking context.” There is often no visual change if you are not using blend modes. Easy to miss in a reset or a “fix blending” snippet.

.widget { isolation: isolate; } /* looks the same, door is shut */

will-change: opacity or will-change: transform

Section titled “will-change: opacity or will-change: transform”

What it is: a hint to the browser: “I might animate this soon — get a layer ready.” Use it sparingly, on elements that actually animate.

Why it traps: if you hint a property that would create a stacking context, the browser may create that context now, before the animation. will-change: opacity on a layout wrapper is a silent trap. will-change: scroll-position does not do this — it is the compositing hints that matter.

.drawer { will-change: transform; } /* optimization hint, also seals the box */

What it is: a performance hint: “nothing inside me paints outside my box.” The browser can skip drawing descendants that sit off this element’s overflow.

Why it traps: paint containment requires a stacking context (and it clips overflowing descendants). If a tooltip or dropdown must poke out of the parent, contain: paint both clips it and traps z-index.

.list-item { contain: paint; } /* cheaper paint, tooltip cannot escape */
  • A modal sits under a sticky header because the header’s ancestor uses transform for a slide animation.
  • A dropdown is covered by the next card because a list item has filter or opacity on hover.
  • z-index: 9999 on a portal’s child does nothing if the portal node still lives under a transformed layout wrapper.

Fix the parent, not the child’s number. Either give that parent a higher z-index in its context, or remove the property that created the extra context if you do not need it.

Stacking decides who is painted on top. Hit-testing decides who got the click. Those are not always the same node — next in this series.