Skip to content
academia.sh

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:

  1. The background and border of the box that establishes the context.
  2. Positioned boxes with a negative z-index value.
  3. The backgrounds of block boxes in flow.
  4. Floated boxes.
  5. Inline content and text.
  6. Positioned boxes whose z-index is auto or 0.
  7. Positioned boxes with a positive z-index value.

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 position value is other than static and whose z-index value is other than auto.
  • Boxes with position: fixed or position: sticky (even without z-index written).
  • Boxes whose opacity value is less than 1.
  • Boxes on which a property like transform, filter, perspective, clip-path, or mask takes a value other than none.
  • Boxes declaring isolation: isolate.
  • Children of a flexible box or grid layout whose z-index value is other than auto.

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-index values 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 whether position alone 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-index does 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, and filter, also establish a context, because they require the box’s content to be processed as a whole.
  • isolation: isolate is 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.

Start typing to search.

↑↓ Esc navigate · open · close