Lesson 15 / 26
Stacking Context
The paint order of boxes; z-index values being compared only among siblings, and the declarations that establish a stacking context.
Contents
Positioning made boxes overlap: a badge sitting over a card, a stuck header over rows. One question was left open — if two boxes overlap at the same point, which one is drawn on top?
The answer does not end with writing z-index. This property has a commonly encountered
behavior: sometimes a higher value ends up underneath. The reason is not a bug, it is a
structure called the stacking context. This lesson defines that structure.
Default Paint Order
There is an order even when z-index is never written. Boxes in the same stacking context
are painted roughly in this order — what is painted first stays at the bottom:
- The background and border of the box that establishes the context.
- Positioned boxes with a negative
z-indexvalue. - The backgrounds of block boxes in flow.
- Floated boxes.
- Inline content and text.
- Positioned boxes whose
z-indexisautoor0. - Positioned boxes with a positive
z-indexvalue.
Two observations follow from this list. First, a positioned box is painted on top of boxes
in flow; there is no need to write z-index. position: absolute is enough for a badge to
appear over a card.
Second, among boxes at the same level, document order is what decides: whatever comes later is painted on top.
z-index Is Compared Only Among Siblings
z-index does not give a box a global elevation. The value is compared only against the
box’s siblings within its own stacking context.
When a box establishes a stacking context, every box inside it is enclosed within that context. What gets compared with boxes outside is no longer the children’s values, it is the value of the box that establishes the context.
// stacking.mjs — resolves stacking contexts as a tree and produces paint order const tree = { name: "root", zIndex: "auto", formsContext: true, children: [ { name: "A", zIndex: 1, formsContext: true, children: [ { name: "A1", zIndex: 999, formsContext: true, children: [] }, { name: "A2", zIndex: 5, formsContext: true, children: [] }, ]}, { name: "B", zIndex: 2, formsContext: true, children: [ { name: "B1", zIndex: 1, formsContext: true, children: [] }, ]}, { name: "C", zIndex: "auto", formsContext: false, children: [] }, ], }; // children inside a context are ordered by z-index, ties broken by document order function paint(node, depth = 0, prefix = "") { const path = prefix ? `${prefix} > ${node.name}` : node.name; const z = node.zIndex === "auto" ? 0 : node.zIndex; console.log(`${" ".repeat(depth)}${node.name.padEnd(4)} z=${String(node.zIndex).padEnd(4)} path=${path}`); const sorted = [...node.children] .map((c, i) => ({ c, i })) .sort((x, y) => { const zx = x.c.zIndex === "auto" ? 0 : x.c.zIndex; const zy = y.c.zIndex === "auto" ? 0 : y.c.zIndex; return zx - zy || x.i - y.i; }); for (const { c } of sorted) paint(c, depth + 1, path); return z; } console.log("paint order (bottom to top, nested):"); paint(tree); console.log("\n--- comparing A1 and B ---"); console.log("A1.z = 999, B.z = 2"); console.log("A1 is not in the root context; it is inside A's (z=1) context."); console.log("Values compared in the root context: A=1, B=2 -> B on top."); console.log("Result: A1 (999) stays BELOW all of B's subtree.");
paint order (bottom to top, nested):
root z=auto path=root
C z=auto path=root > C
A z=1 path=root > A
A2 z=5 path=root > A > A2
A1 z=999 path=root > A > A1
B z=2 path=root > B
B1 z=1 path=root > B > B1
--- comparing A1 and B ---
A1.z = 999, B.z = 2
A1 is not in the root context; it is inside A's (z=1) context.
Values compared in the root context: A=1, B=2 -> B on top.
Result: A1 (999) stays BELOW all of B's subtree.
The last block is the exact statement of the problem this lesson solves. Box A1’s z-index
value is 999 — the highest value on the page. Despite this, it stays below box B, whose
value is 2.
The reason is that box A1 is not in the root context. Two values are compared in the root
context: 1 for A and 2 for B. B wins and is painted, along with its whole subtree, on
top of A. Box A1’s value of 999 is only ever compared against its sibling A2.
The actionable conclusion is this: if a box is not rising as high as expected, the fix is not to raise the value. Raising the value to a million does not help either. The fix is to find which box in the ancestor chain establishes a context and make the comparison at that level.
What Establishes a Stacking Context
A box establishes a stacking context when one of these conditions holds:
- The document’s root element (always establishes a context).
- Boxes whose
positionvalue is other thanstaticand whosez-indexvalue is other thanauto. - Boxes with
position: fixedorposition: sticky(even withoutz-indexwritten). - Boxes whose
opacityvalue is less than 1. - Boxes on which a property like
transform,filter,perspective,clip-path, ormasktakes a value other thannone. - Boxes declaring
isolation: isolate. - Children of a flexible box or grid layout whose
z-indexvalue is other thanauto.
The middle of this list stands out. Writing opacity: 0.99 — a change that will not be
visually noticeable — establishes a stacking context and isolates every z-index calculation
inside that box from the outside. The same holds for a transform declaration written to give
a box motion.
That a declaration with no apparent relation to stacking changes stacking looks surprising, but there is a reason: all of these properties require the box and its content to be processed as a whole. Transparency is computed by blending everything inside the box together rather than separately, and this forces them to be treated as a single layer.
isolation: isolate
The isolation: isolate declaration, the item before the last on the list, is the side-effect-free
way to establish a stacking context. It is written when a box’s internal z-index values
should not leak outward, and it changes nothing else.
This is useful on pages where multiple components coexist: if each component establishes its
own context, internal z-index values can stay small and local. One component’s 10 does not
compete with another component’s 10.
A z-index Scale
This lesson’s practical result is a writing discipline.
z-indexvalues are kept small and named. A page has a handful of layers — base, stuck header, dropdown layer, top layer — and each is given sparse values like 1, 10, 100. The gaps in between leave room for adding a layer later.- Before reaching for
z-index, it is checked whetherpositionalone is enough for a box to stay on top; a positioned box is already above boxes in flow. - Components are isolated with
isolation: isolate, so their internal values do not leak outward.
/* station.css — step 14: layer scale */ .card { position: relative; isolation: isolate; } .card .status-badge { position: absolute; z-index: 1; inset-block-start: 8px; inset-inline-end: 8px; } .measurement-table thead th { position: sticky; z-index: 10; inset-block-start: 0; background-color: #f5f7f8; } .skip-link:focus { position: fixed; z-index: 100; inset-block-start: 8px; inset-inline-start: 8px; }
The isolation: isolate written on the card ensures the badge’s z-index: 1 value never
competes with anything outside the card. The stuck header stays at layer 10, the focused skip
link at layer 100; the order among the three is defined for the whole page and is readable.
Summary
- Paint order is defined even without writing
z-index; positioned boxes are painted above boxes in flow, and boxes at the same level are painted in document order. z-indexdoes not give a global elevation; it is compared only against the box’s siblings within its own stacking context.- When a box establishes a context, every value inside it is enclosed within that context; what gets compared with the outside is the value of the box that establishes the context.
- Declarations that look unrelated to stacking, like
opacity,transform, andfilter, also establish a context, because they require the box’s content to be processed as a whole. isolation: isolateis the side-effect-free way to establish a context; it is used to keep a component’s internal layer values local.
Next Step
Throughout this topic, boxes were sized, arranged, and overlapped. One situation has not been addressed yet: what happens when content does not fit its box? A long station name does not fit a narrow cell, a wide measurement table does not fit a narrow screen. The next lesson takes up what overflow is and what each of the clipping, scrolling, and hiding options costs.
To keep your progress and take notes, Log in
My notes
Log in to take notes.