CSS layout model
How the CSS box model works
Every element is four nested rectangles: content → padding → border → margin. Background paints across content and padding, and sits under the border. Margin is always transparent; it is spacing between elements rather than part of the element's visible body.
Interviews open with a sizing question: how wide is this?
.box { width: 200px; padding: 20px; border: 2px solid; }
/* renders 244px wide: 200 + 20+20 + 2+2 */
By default (box-sizing: content-box) width sizes only the content area, and padding and border are added on top. With box-sizing: border-box, width describes the border box: the element renders exactly 200px, with padding and border carved out of the inside. That predictability is why nearly every codebase resets it globally:
*, *::before, *::after { box-sizing: border-box; }
❌ "width: 200px means the element takes 200px". True only under border-box. Under the default, an element with padding is always wider than its width.
Why vertical margins collapse
Vertical margins of adjacent block elements collapse to the larger of the two:
p { margin-bottom: 24px; margin-top: 16px; }
/* gap between two <p>: 24px, not 40px */
Horizontal margins never do this. Collapsing also happens between a parent and its first or last child: if the parent has no top border, no top padding, and no inline content before the child, the child's margin-top leaks out and pushes the parent down instead of appearing inside it. The classic sighting is a heading's margin showing up outside its gray container. Any top padding or border on the parent stops it.
The five position values
static: the default. Insets (top/left/…) andz-indexdo nothing.relative: offset from its own normal spot. Its original space is preserved, so neighbors don't move. Mostly used to make an element an anchor (see below).absolute: removed from flow, reserves no space, positioned by insets.fixed: removed from flow, pinned to the viewport, ignores scrolling.sticky: normal flow until a scroll threshold, then pins. It needs at least one inset (top: 0) or it never sticks.
What position: absolute is relative to
An absolutely positioned element positions against the nearest ancestor whose position is not static. This ancestor is its containing block, and it is the reason for the idiom:
.card { position: relative; } /* becomes the anchor */
.card .badge { position: absolute; top: 8px; right: 8px; }
❌ "absolute is relative to its parent". Only if the parent is positioned. With no positioned ancestor at all, it positions against a viewport-sized box at the top of the document and ends up somewhere surprising.
The middle notes cover which ancestors can steal the containing block and why margins collapse at all. When layout confuses you before then, skip the guessing and check DevTools: the box-model diagram shows computed content, padding, border, and margin for any selected element.
How the containing block is chosen
"Nearest positioned ancestor" is the junior approximation. The precise rules:
static/relative/sticky→ nearest block container's content boxabsolute→ nearest positioned ancestor's padding boxfixed→ the viewport
Padding box matters: width: 50% inside a position: relative ancestor of width: 400px; padding: 20px is 220px, not 200px.
A whole family of visual properties turns ANY ancestor into the containing block for absolute and fixed descendants: transform (and translate/rotate/scale), perspective, filter, backdrop-filter, will-change: transform, contain: layout|paint, content-visibility: auto. This is the trap that breaks production UIs:
.drawer { transform: translateX(0); } /* even a no-op transform */
.drawer .modal { position: fixed; inset: 0; }
/* .modal now fills .drawer and scrolls with the page, not the viewport */
❌ "fixed is always relative to the viewport". One animated ancestor and it isn't, which is why overlay libraries portal modals to <body>.
Percentages resolve against the containing block. width/left/right/margin/padding resolve against its width (yes, padding-top: 10% too); only height/top/bottom use its height.
Why z-index isn't working
z-index doesn't compete on a global number line. It competes among siblings within one stacking context, and a context is atomic: its children are flattened and can never interleave with anything outside it. A context is created by, among others:
- positioned element with
z-indexother thanauto;fixed/stickyalways - flex/grid item with
z-indexother thanauto(nopositionneeded) opacity< 1,transform/filter/clip-path/mask≠ none,mix-blend-modewill-changenaming any of these;contain: layout|paint;isolation: isolate
So opacity: 0.99 on a wrapper silently caps every descendant's z-index at the wrapper's own level. When z-index misbehaves, walk up the tree looking for whichever ancestor created the context; the fix lives on that ancestor rather than in a bigger number.
Also: z-index is inert on position: static… except on flex/grid items, where it applies directly.
Block formatting contexts
A BFC is a sealed mini-layout. Created by display: flow-root, overflow other than visible/clip, floats, inline-block, table cells, flex/grid items, contain: layout|paint, multicol. It has three effects:
- contains its internal floats (the parent wraps them)
- refuses to overlap external floats
- stops parent↔child margin collapsing
Effect 3 is the modern reason to care, and display: flow-root is the way to request a BFC without the clipped shadows and surprise scrollbars of overflow: hidden. ❌ "overflow: hidden just clips". It changes layout behavior, which is why deleting a random overflow: hidden can un-contain floats or resurrect margin leaks.
Margin collapsing edge cases
- An empty block (no border/padding/content/height) collapses its own top and bottom margins into one.
- Negative margins: result = largest positive + most negative.
24pxmeeting-40pxyields-16px, an overlap. - Floats and absolutely positioned elements never collapse. Flex/grid children never collapse either, one more reason to space with
gapinstead of margins. - More than two margins can merge into a single one (sibling + parent-edge + empty block chains).
Paint order inside one stacking context
Back to front, the browser paints:
- the context element's own background and border
- children with negative z-index
- in-flow block descendants (backgrounds/borders)
- floats
- inline content
- children at
z-index: 0/auto - children with positive z-index
Interviewers probe two consequences of this list. A z-index: -1 child slides behind its parent's background, but only while the parent is not a stacking context; the moment the parent becomes one, step 1 floors the child. And z-index: auto vs z-index: 0 on a positioned element paint in the same slot yet are not interchangeable: 0 creates a stacking context, auto doesn't. Changing one to the other alters nothing visible until a descendant's z-index silently stops escaping.
How the top layer changes overlay code
dialog.showModal() and the popover API promote elements into the top layer, above every stacking context, where z-index is not consulted; ordering is promotion order. For new overlay work this deletes the classic problems: z-index bidding wars, clipping by overflow ancestors, and transform-hijacked fixed. Hand-rolling overlays with position: fixed; z-index: 9999 is legacy technique.
For everything that isn't an overlay, the tool is:
.widget { isolation: isolate; }
This creates a stacking context with zero visual side effects. It seals the widget's internal z-index games off from the page and prevents the page from reaching in. Component boundaries should isolate; then a tiny app-level token scale (--z-dropdown: 100; --z-toast: 200; …) is all the z-index the codebase needs.
What CSS containment does to positioning
contain: layout|paint|strict and content-visibility: auto let the engine skip layout and paint for offscreen or independent subtrees, a real win on long pages. The fine print: they create a containing block for absolute/fixed descendants and a stacking context. Sprinkling content-visibility: auto over a page can therefore move fixed elements and re-scope z-indexes. Performance properties and positioning semantics come as a package, and will-change: transform carries the same side effects.
How to debug stacking and overlap bugs
Overlap bugs are ancestor bugs. The workflow: pick the two elements that stack wrongly, walk each one's ancestor chain to the nearest stacking context (DevTools "Layers"/context badges, or scan for the creation list), and compare those two contexts; the fix lives on them. For geometry bugs, the box-model panel plus a throwaway * { outline: 1px solid red } shows collapsed margins, out-of-flow boxes, and phantom widths faster than reading source. If a page defies reason, binary-search it: delete half the DOM in DevTools and see if the bug survives.
At the architecture level:
- Never transform or filter an ancestor of fixed UI. Design the DOM so overlays are portaled to
<body>(or use the top layer) before someone animates a wrapper. - Collapsing made sibling margins unpredictable between components that can't see each other, which is why
gapin flex/grid won. New spacing code should not ship sibling margins. z-index: 9999in a diff means someone lost a fight with a stacking context and paid ransom instead of finding it.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.