Stacking, Paint Order & Overlapping Elements
Building a Web Browser Engine: Layout & Rendering
Chapter 9 · Stacking, Paint Order & Overlapping Elements
This engine has no position: absolute, no floats — normal-flow block layout is all it knows. So how can two boxes ever genuinely overlap at all? There's exactly one way already fully supported: a negative margin. This chapter uses that as the real, concrete overlap case, then builds a real (but deliberately narrow) z-index feature on top of it.
position, opacity, transform, and z-index together determine when a new stacking context even exists, and z-index values only ever compare within one. This chapter builds one honest, narrow slice: z-index re-sorting a single box's own direct children, nothing more.
Negative-Margin Overlap: Verified Working, With Zero New Code
Two siblings under one container: top_box (100×40, red), bottom_box (100×40, blue, margin: -20px 0 0 0).
top_box.border_box() = Rect(0, 0, 100, 40). bottom_box.border_box() = Rect(0, 20, 100, 40) — genuinely overlapping top_box's own bottom 20 pixels. Chapter 2's expand_rect_by() and Chapter 3's own position/height-accumulation formulas are pure addition and subtraction — nothing in them ever assumed a margin had to be positive. A negative value simply flows through the same math correctly, by construction.
(50, 30): blue. bottom_box, later in source order, correctly paints on top of top_box — Chapter 7's own source-order rule, unmodified, already gets this right. No z-index needed for the default case at all.
A Real z-index — Scoped to Siblings, Never Across Parent/Child
[x(z-index:1), y(z-index:0)]. Paint order after the sort: ['yellow', 'green'] — y paints first despite coming second in the source, x paints last (on top), matching real CSS: z-index reorders paint, independent of DOM order.
z-index: 0: paint order comes out ['orange', 'purple'] — exactly matching source order, with no special tie-breaking code needed at all. Python's own sorted() is a stable sort: equal keys never change relative order.
A Real Bug: Sorting Globally Reintroduces Chapter 7's Own Bug
A plausible-looking shortcut: just collect every box in the whole tree and sort them all by z-index, once. Real CSS z-index never works this way — it only ever compares elements within the same stacking context, never an arbitrary parent against its own child.
P (background: red, z-index: 5) containing child C (background: blue, default z-index: 0). The global sort puts P's own entry after C's, purely because 5 > 0 — the display list comes out ['blue', 'red']. Checking a point inside both boxes: red. P paints completely over its own child, hiding it — this is the exact same visual bug Chapter 7 found and fixed (a parent's background covering its own content), reintroduced here through a totally different mechanism: not "children before self," but "global z-index sort that doesn't know or care about tree structure at all."
build_display_list_zindex(): ['red', 'blue']. The same point now correctly resolves to blue. paint_background(layout_box, display_list) runs unconditionally, before P's own children are ever sorted or recursed into — P and C are never compared to each other by z-index at all, because they're never siblings of one another.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Negative margins working correctly with zero new code | Chapter 4's own auto-margin algorithm — parse_length has always happily returned negative floats for a value like "-20px"; nothing anywhere in this course's own layout math ever assumed non-negative margins, so this "just worked" the moment it was tested |
| z-index reintroducing Chapter 7's own exact bug via a new mechanism | Chapter 6's own font-size bug and Chapter 8's own bounds-clipping bug — a recurring pattern across this whole course: the same class of visual mistake (something painting over, or measuring past, what it should) keeps resurfacing through genuinely different code paths, not because earlier fixes were wrong, but because each new feature is a new opportunity to reintroduce an old category of mistake |
| z-index scoped strictly to direct siblings | The honest boundary of this whole course's own stacking model — real CSS stacking contexts, position-triggered new contexts, and isolation rules are named directly as out of scope, not silently ignored |
Hands-On Exercises
Build three siblings with z-index values 2, 0, and 1 respectively (in that source order), each with a distinct background color. Run them through build_display_list_zindex() and confirm the resulting paint order is sorted purely by z-index (lowest painted first), independent of the original source order.
Build a box with an invalid z-index value, e.g. 'auto' (a real CSS keyword this course's own simplified model doesn't parse as a number). Call get_z_index() on it directly and confirm it falls back to 0 rather than raising an error, and explain which part of the function is responsible.
Reproduce this chapter's own global-z-index bug directly: build a parent with z-index: 5 containing a child with default z-index: 0, lay it out for real, and build display lists with both build_display_list_naive_global_zindex() and build_display_list_zindex(). Confirm the two disagree at a point inside both boxes, and explain specifically why collect_all_boxes() is the root of the problem, even though get_z_index() itself is computing perfectly correct values.
Chapter 9 Quick Reference
- The only real overlap this engine supports: negative margins — verified working correctly with zero new layout code, since the existing formulas never assumed margins were positive
- Default stacking: source order — a later sibling paints on top of an earlier one, already correct via Chapter 7's own rule, with no z-index needed
- z-index, correctly scoped: re-sorts a single box's own direct children only — never compares a parent against its own descendants
- Real bug found and fixed: sorting the ENTIRE tree by z-index globally reintroduces Chapter 7's own exact "parent paints over child" bug, since a parent's own z-index can outrank its child's in a flat, structure-blind sort
- Verified: higher z-index correctly overrides source order among siblings; equal z-index siblings keep source order via Python's own stable sort
- Next chapter: Capstone — rendering a real web page end to end, all the way to a pixel-checked image