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.

Scope note
Real CSS stacking contexts are a genuinely deep subject — 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).

Verified directly — the overlap is real, and required no new layout code at all
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.
Verified directly — the existing paint-order rule already handles this overlap correctly
Checking the actually-visible color at a point inside the real overlap band, (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

def get_z_index(layout_box): style = box_style(layout_box) z = style.get('z-index', '0') try: return int(float(z)) except (ValueError, TypeError): return 0 def build_display_list_zindex(layout_box, display_list=None): if display_list is None: display_list = [] paint_background(layout_box, display_list) # THIS box's own bg, unconditionally ordered_children = sorted(layout_box.children, key=get_z_index) # SIBLINGS ONLY, stable sort for child in ordered_children: build_display_list_zindex(child, display_list) return display_list
Verified directly — higher z-index paints on top, even against source order
Two siblings, source order [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.
Verified directly — equal z-index siblings keep their original source order
Two siblings, both left at the default 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

def build_display_list_naive_global_zindex(root): all_boxes = collect_all_boxes(root) # flattens the WHOLE tree, ignoring structure all_boxes_sorted = sorted(all_boxes, key=get_z_index) display_list = [] for box in all_boxes_sorted: paint_background(box, display_list) return display_list

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.

Verified directly — a parent's own high z-index makes it paint over its own child, hiding it completely
Parent 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."
Verified directly — sibling-scoped sorting fixes it, regardless of either box's own z-index value
Same two boxes, through 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 findingWhat it connects to
Negative margins working correctly with zero new codeChapter 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 mechanismChapter 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 siblingsThe 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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