Skip to content

The Box Model & Layout

Every element the browser renders becomes a rectangular box. Layout is the process of sizing those boxes and placing them. Understanding the box and the default flow removes most of the guesswork from CSS.

Four concentric layers, from the inside out:

┌─────────────────── margin (transparent, outside) ────────────┐
│ ┌───────────────── border ───────────────────────────────┐ │
│ │ ┌───────────── padding (inside, painted by bg) ─────┐ │ │
│ │ │ │ │ │
│ │ │ content │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
  • Content box — where text and child boxes go. width/height target this box by default.
  • Padding box — space inside the border. The element’s background paints here (and under the border).
  • Border box — the visible edge.
  • Margin box — space outside the border. Always transparent; the parent’s background shows through.
.box {
width: 300px;
padding: 20px;
border: 2px solid black;
margin: 16px;
}
/* Rendered width of the border box: 300 + 20 + 20 + 2 + 2 = 344px */
/* Space it occupies in flow: 344 + 16 + 16 = 376px */
padding: 10px; /* all four sides */
padding: 10px 20px; /* block (top/bottom) | inline (left/right) */
padding: 10px 20px 5px; /* top | left+right | bottom */
padding: 10px 20px 5px 0; /* top | right | bottom | left (clockwise) */

margin and border-width use the same pattern. Mnemonic for the four-value form: TRouBLe — Top, Right, Bottom, Left.

box-sizing changes what width and height mean.

Value width: 300px means
content-box (default) content is 300px; padding and border add on top
border-box the border box is 300px; padding and border eat into the content

Content-box makes width: 100% plus any padding overflow its parent. Set border-box once, globally:

*, *::before, *::after { box-sizing: border-box; }

The *::before, *::after part matters — pseudo-elements do not inherit box-sizing from * because * does not match them.

.panel {
width: 100%;
max-width: 60ch; /* caps it */
min-height: 10rem;
height: auto;
}

min-width/min-height beat max-*, which beat width/height. Also useful:

.thing {
width: min-content; /* narrowest without overflowing (longest word) */
width: max-content; /* widest it wants to be, no wrapping */
width: fit-content; /* max-content, capped by the available space */
}

fit-content is the clean way to shrink-wrap a block element to its content while keeping it a block.

With no positioning, floats, flex, or grid, boxes lay out in normal flow:

  • Block boxes stack vertically, each taking the full available inline width, in source order.
  • Inline boxes flow horizontally inside a block, wrapping onto new line boxes as needed.

That is the entire default. Flex and Grid replace normal flow inside the element they’re set on; the element itself is still a box in its parent’s flow.

display decides two things: how the box behaves in its parent (outer display type) and how its children are laid out (inner display type).

Value Outer Inner Notes
block block flow full width, respects all box properties
inline inline flow flows with text
inline-block inline flow-root flows with text, but is a block internally
flex block flex children become flex items
inline-flex inline flex
grid block grid children become grid items
inline-grid inline grid
flow-root block flow-root block that contains its floats and blocks margin collapse
none box is not generated at all
contents element’s own box is removed; children stay

Modern CSS also accepts the explicit two-value form: display: inline flex is the same as display: inline-flex.

span {
width: 200px; /* ignored */
height: 50px; /* ignored */
margin-block: 20px;/* ignored for layout */
padding-block: 20px;/* painted, but does NOT push other lines away */
}

Vertical padding and borders on an inline box are drawn, but they overlap surrounding lines instead of affecting line height. If you need box behaviour in a text flow, use inline-block.

Takes up space In the accessibility tree Clickable
display: none no no no
visibility: hidden yes no no
opacity: 0 yes yes yes

An opacity: 0 element is still focusable and clickable — a common source of invisible click targets.

Removes the element’s own box while keeping its children in the layout. Useful for making a wrapper’s children into direct flex/grid items:

<div class="grid">
<div style="display: contents">
<div>a</div>
<div>b</div>
</div>
</div>

Both inner divs become grid items of .grid.

Adjacent vertical margins in normal flow collapse into one, whose size is the larger of the two (or, with a negative margin, the sum of the largest positive and the most negative).

This happens in exactly three situations:

1. Adjacent siblings

<p style="margin-bottom: 30px">A</p>
<p style="margin-top: 20px">B</p>
<!-- Gap is 30px, not 50px -->

2. Parent and first/last child — if nothing separates them:

<div class="parent"> <!-- no padding, no border -->
<p style="margin-top: 20px">Child</p>
</div>
<!-- The 20px escapes the parent and pushes the PARENT down -->

3. Empty blocks — a block with no content, padding, border or height collapses its own top and bottom margins together.

Collapsing stops when something sits between the margins:

.parent { padding-block-start: 1px; } /* anything non-zero */
.parent { border-block-start: 1px solid transparent; }
.parent { display: flow-root; } /* cleanest: new formatting context */
.parent { overflow: hidden; } /* works, but clips */

Margins also never collapse in these contexts:

  • Flex and grid containers — flex/grid items never collapse margins with anything.
  • Floated or absolutely positioned elements.
  • Elements that establish a new block formatting context (BFC).

A BFC is a region of the page that lays out independently: floats inside it do not escape, floats outside do not intrude, and its margins do not collapse with the outside.

A BFC is created by, among others: the root element, floats, absolutely positioned elements, display: inline-block, display: flow-root, table cells, flex/grid items, and any block container with overflow other than visible.

display: flow-root exists specifically so you can create one without side effects. The old overflow: hidden clearfix works only because it makes a BFC.

.clearfix { display: flow-root; } /* contains floated children */

position changes how a box is placed and what top/right/bottom/left (the inset properties) mean.

Normal flow. Inset properties and z-index are ignored.

Laid out in normal flow, then visually shifted by the insets. The space it originally occupied is preserved — surrounding content does not move.

.nudge { position: relative; top: -2px; }

Its main job in practice is to become a positioning ancestor for absolutely positioned children.

Removed from normal flow entirely (siblings behave as if it doesn’t exist), then positioned against its containing block: the padding box of the nearest ancestor whose position is not static. If there is none, it positions against the initial containing block (viewport-sized, at the document origin).

.card { position: relative; }
.card .badge {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
}

With no insets specified, an absolute box sits at its static position — where it would have been in flow — which is often exactly what you want.

Setting opposite insets stretches it:

.overlay { position: absolute; inset: 0; } /* = top/right/bottom/left: 0 */

Removed from flow and positioned against the viewport, so it does not scroll.

.header { position: fixed; inset-block-start: 0; inset-inline: 0; }

A hybrid: laid out in normal flow, but once its scroll container reaches the given offset it stops moving and behaves like fixed — until its parent scrolls out of view.

th {
position: sticky;
inset-block-start: 0;
background: white; /* it will scroll over content, so it needs one */
}

Requirements people miss:

  • You must specify at least one inset (top, bottom, left, or right). A sticky element with no offsets never sticks.
  • It sticks within its parent. It can never leave the parent’s box, so a sticky element in a short parent barely moves.
  • No ancestor between it and the scroll container may have overflow: hidden, scroll, or auto. That ancestor becomes the scroll container, and the element sticks to that instead — usually invisibly.
position Containing block
static, relative, sticky content box of the nearest block-container ancestor
absolute padding box of the nearest ancestor with position != static
fixed the viewport, unless a transformed/filtered/contained ancestor intercepts it

This is also why percentage widths resolve differently for absolute elements: width: 50% on an absolute child is 50% of the ancestor’s padding box, not its content box.

Boxes paint in a defined order. Within a stacking context, painting proceeds roughly like this, back to front:

  1. The context root’s own background and borders
  2. Descendants with negative z-index
  3. In-flow, non-positioned block boxes
  4. Floats
  5. In-flow inline content
  6. Positioned descendants with z-index: auto or 0
  7. Descendants with positive z-index

z-index only applies to positioned elements — and to flex and grid items, which accept z-index even when position: static.

A stacking context is self-contained. Its children’s z-index values are only compared with each other, never with elements outside. So a child with z-index: 999999 can still paint below an unrelated element if its ancestor’s stacking context sits lower.

<div class="a"> <!-- z-index: 1 -->
<div class="modal"> <!-- z-index: 99999 -->
</div>
<div class="b"></div> <!-- z-index: 2 -> paints above the whole of .a -->

.modal loses. 99999 is compared only against .a’s other children.

  • The root element (<html>)
  • position: relative|absolute with z-index other than auto
  • position: fixed or sticky (always, regardless of z-index)
  • A flex or grid item with z-index other than auto
  • opacity less than 1
  • transform, filter, backdrop-filter, perspective, clip-path, mask, mix-blend-mode other than normal
  • isolation: isolate
  • contain: layout|paint|strict|content
  • will-change naming any property that would create one

Practical rule: keep a small, documented set of z-index values (say 10, 20, 30, 40) as custom properties, and isolate components.

:root {
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-toast: 50;
}

Controls what happens when content is larger than its box.

Value Behaviour
visible (default) content spills out, still visible and interactive
hidden clipped; programmatically scrollable (JS can set scrollTop)
clip clipped; no scrolling at all, not even programmatic
scroll clipped, scrollbars always present
auto clipped, scrollbars appear only when needed
.pane { overflow: auto; } /* both axes */
.row { overflow-x: auto; overflow-y: hidden; }

Details that bite:

  • overflow: hidden|scroll|auto establishes a BFC and makes the element a scroll container, which breaks position: sticky for descendants relative to the page.
  • If one axis is visible and the other is not, the visible one computes to auto. You cannot clip horizontally while overflowing vertically — except with overflow-x: clip; overflow-y: visible, which does work.
  • overflow-clip-margin lets clip keep a few pixels of bleed: overflow: clip; overflow-clip-margin: 4px;.

Related:

.scroller {
overscroll-behavior: contain; /* stop scroll chaining to the page */
scroll-behavior: smooth; /* smooth anchor/scrollTo jumps */
scrollbar-gutter: stable; /* reserve space so layout doesn't shift */
}

Text overflow is separate:

.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

All three are required — text-overflow alone does nothing.

  • box-sizing: border-box globally, always.
  • Inline boxes ignore width, height, and vertical margins; vertical padding paints but does not push.
  • Vertical margins collapse between siblings and between a parent and its first/last child; display: flow-root, padding, a border, or a flex/grid container stops it.
  • absolute positions against the nearest non-static ancestor’s padding box; fixed positions against the viewport unless a transformed ancestor intercepts it.
  • sticky needs an inset, sticks only within its parent, and dies inside a scrolling ancestor.
  • z-index is compared only within a stacking context; isolation: isolate is how you sandbox one.