Skip to content

6.2.3 — The Box Model, Units & Custom Properties

Two boxes, side by side, in a container 600 pixels wide:

css
.col { width: 300px; padding: 20px; border: 1px solid; }

They wrap onto separate lines. Each one is 342 pixels wide, not 300, and the two together need 684 pixels. The width you wrote did not describe the box; it described one part of the box.

1. The four rings, and the property that changes what width means

Every element is drawn as four nested rectangles.

marginborderpaddingcontentbox-sizing: content-box (the default)width = content only300 + 20 + 20 + 1 + 1 = 342 on screenbox-sizing: border-boxwidth = content + padding + border300 on screen; content shrinks to 258margin is outside `width` either way— it never counts
The same declared width, two different boxes on screen. Margin sits outside both interpretations.

content-box is the CSS default and the source of the arithmetic above: width sizes the innermost rectangle, and padding and border are added on top.

border-box makes width mean the whole visible box, so padding and border are subtracted from the inside instead. Declare 300 and get 300.

Nearly everybody now sets border-box globally:

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

Why the default is the wrong one is a genuine piece of history rather than a mistake in the specification. The original CSS box model specified content-box. Internet Explorer 5 implemented what became border-box instead, and for years that difference was the single largest source of cross-browser layout bugs. When the standards settled, the specification's version won on correctness — and then, in practice, every developer discovered that the browser everyone had criticised had the more useful behaviour, because "make this box 300 wide" is what people mean far more often than "make its contents 300 wide". box-sizing was added so you could choose, and choosing border-box is now so standard that most frameworks apply it for you.

Margin sits outside both. It is space between boxes, never part of one, which is why a 100%-wide element with a margin overflows its parent.

2. Margin collapsing, the behaviour that looks like a bug

Two stacked paragraphs, the first with margin-bottom: 32px, the second with margin-top: 24px. The gap between them is 32 pixels, not 56.

Adjacent vertical margins collapse: the larger one wins and the smaller disappears. It only ever happens vertically, and never in a flex or grid container.

Three situations produce it:

Between siblings — the case above.

Between a parent and its first or last child. A <div> with no padding or border containing an <h2> with margin-top: 24px does not get 24 pixels of space inside it. The child's margin escapes and becomes the parent's margin, pushing the whole parent down instead. This is the one that produces "why is there space above my card" bug reports.

An empty element's own top and bottom margins collapse through each other.

The rule exists for a reason that is easy to forget in an era of components: it was designed for documents. A run of headings and paragraphs each carrying their own margins should produce even spacing rather than accumulating gaps, and collapsing gives you that for free.

What stops it: any padding or border between the two margins, overflow set to anything other than visible, display: flow-root, absolute positioning, floating, or being a flex or grid item.

display: flow-root is the modern, side-effect-free answer. It means "make a new block formatting context and nothing else" — which is precisely what people were doing with overflow: hidden for years while also accidentally clipping their dropdown menus.

css
/* The card's own padding already blocks collapsing.  */
.card { padding: 1px 0; }          /* works, but adds a pixel you did not want */
.card { overflow: hidden; }         /* works, and silently clips anything overflowing */
.card { display: flow-root; }       /* works, and does nothing else */

The alternative strategy, and the one most modern codebases use, is to sidestep collapsing entirely: stop putting margins on components at all, and let the container create spacing with gap in flex or grid. Then there are no adjacent margins to collapse, spacing lives in one place, and a component dropped into a new context does not bring its old spacing with it.

3. Block, inline, and the mysterious gap under an image

display decides two things at once: how the box behaves in its parent's layout, and what layout it establishes for its own children. The modern two-value syntax makes this explicit — display: block flow — but the single keywords are what you will see.

block takes the full width available and stacks vertically. Width and height apply.

inline flows with text. width and height are ignored, vertical padding and margin do not push anything away (the padding draws, but it overlaps the surrounding lines), and horizontal margins do work. This is the source of "why won't my <span> take a height".

inline-block flows with the text like a word but is a block internally, so width, height and all padding apply. Useful for buttons and tags.

flow-root is a block that establishes a fresh formatting context, from the section above.

none removes the element from the layout entirely: no box, no space, invisible to the accessibility tree.

The image gap, and the inline layout model behind it

html
<div style="border: 1px solid"><img src="cat.jpg" width="200"></div>

There are about four pixels of space below the image, inside the border, and no CSS you wrote asked for it.

The cause is that <img> is an inline element, so it sits on a text baseline — the line letters rest on. The room below the baseline exists for descenders, the tails of g, y and p. There is no text here, but the line box still reserves that space because the image is being laid out as if it were a large character.

Three fixes, and it is worth knowing which one applies when:

css
img { display: block; }         /* it is no longer on a baseline */
img { vertical-align: bottom; } /* keep it inline, align to the line box bottom */
.wrapper { line-height: 0; }    /* remove the descender space in the wrapper */

display: block is right when the image is on its own. vertical-align: bottom is right when the image genuinely sits inside a line of text.

The same descender space explains inline-block elements that mysteriously do not line up, and the notorious whitespace gap between inline-block boxes — the newline between two tags in your HTML is a space character, and a space character is content. Flexbox removes both problems by not using inline layout at all, which is one of the quieter reasons it took over.

4. Units: which one, and why

Absolute

px is the CSS pixel, and it is not a device pixel. On a phone with a 3× display, one CSS pixel is a 3×3 block of hardware pixels; the browser reports the ratio as devicePixelRatio. The CSS pixel is defined to be roughly the same apparent size across devices, which is what makes it usable at all. Everything else in this list — pt, cm, in — is defined in terms of it and only matters for print stylesheets.

Relative to font size

em is relative to the font size of the element itself, except in the font-size property, where it means the parent's font size. That exception is what makes em compound:

css
.menu    { font-size: 16px; }
.menu li { font-size: 0.9em; }   /* nested lists: 14.4px → 12.96px → 11.7px … */

Each level multiplies again. Nested menus shrinking into unreadability is this, every time.

rem is relative to the root element's font size, so it never compounds. It is the right default for typography and spacing.

And here is the accessibility argument that decides it. A user who sets a larger default font size in their browser is changing the root font size. Everything sized in rem scales with them; everything in px ignores them. 1rem is 16 pixels by default, and treating that as a fixed conversion is exactly the assumption that breaks for the users who most need it to work.

em still earns its place inside a single component, where you want padding and border-radius to scale with that component's own text:

css
.badge {
  font-size: 0.875rem;   /* fixed relative to the root */
  padding: 0.4em 0.8em;  /* scales with the badge's own font size */
  border-radius: 0.3em;
}

Change the badge's font-size and the whole thing scales proportionally. That is the one job em does better than anything else.

Relative to the viewport

vw and vh are one percent of the viewport width and height. vmin and vmax take the smaller and larger of the two.

100vh on a phone is the famous broken one. Mobile browsers show and hide their toolbars as you scroll, so the visible height changes. vh was defined against the largest possible viewport, which means a 100vh hero section is taller than the screen when the toolbar is showing, and its bottom is cut off — the "sign in" button under the toolbar, an extremely common bug.

The newer units name the three states directly:

UnitMeansBehaviour
svhsmall viewport heighttoolbars visible — always fits
lvhlarge viewport heighttoolbars hidden — same as old vh
dvhdynamic viewport heightchanges as toolbars move

Use svh when something must always fit — a full-screen layout with a button at the bottom. dvh gives the nicest result for a decorative hero, at the cost of the layout resizing during a scroll, which can look restless. Chapter 6.2.5 covers the responsive picture in full.

Content-relative and layout units

ch is the width of the 0 glyph in the current font, and it is genuinely the best unit for one job: max-width: 65ch on body text gives the reading line length typography research keeps recommending, and it adapts automatically when the font changes.

fr exists only inside grid and means a fraction of the leftover space (Chapter 6.2.4).

calc(), min(), max() and clamp()

These are the functions that let a single declaration replace a media query.

css
.container {
  /* (1) Mix units freely — this is calc()'s whole point. */
  width: calc(100% - 2rem);

  /* (2) Whichever is SMALLER. Caps the width on big screens. */
  width: min(1200px, 100% - 2rem);

  /* (3) Whichever is LARGER. Enforces a floor. */
  min-height: max(50vh, 400px);
}

h1 {
  /* (4) clamp(minimum, preferred, maximum) */
  font-size: clamp(1.75rem, 4vw + 1rem, 3.5rem);
}

Line (1) is the classic use: percentages and fixed lengths cannot be mixed any other way. Note that calc needs spaces around + and -, because -2rem would otherwise read as a negative number.

Lines (2) and (3) read backwards at first. min() sets a maximum — taking the smaller of two values means you can never exceed the smaller one. max() sets a minimum. Read them as "pick the smaller" and "pick the larger" and the confusion goes away.

Line (4) is fluid typography in one line: never below 1.75rem, never above 3.5rem, and in between it scales with the viewport. The + 1rem in the middle is not decoration — a pure vw value scales to zero on a tiny screen and, more importantly, a font size that is purely viewport-based cannot be scaled by a user zooming, which is an accessibility failure. Including a rem term keeps zoom working.

5. Custom properties, which are not variables

css
:root {
  --brand: #0b62d6;
  --space: 1rem;
}
.button { background: var(--brand); padding: var(--space) calc(var(--space) * 2); }

That looks like a preprocessor variable and behaves quite differently. Four properties matter.

They are inherited. A custom property set on an element is visible to every descendant. That is what makes theming a subtree trivial:

css
:root       { --surface: white; --ink: #111; }
.dark-panel { --surface: #16181d; --ink: #f2f4f7; }   

.card { background: var(--surface); color: var(--ink); }

Every .card inside .dark-panel flips, with no extra rules and no .dark-panel .card selector. A preprocessor variable cannot do this at all, because it is gone before the browser ever sees the CSS.

They are live at runtime. JavaScript can read and write them, and every element that uses one updates:

js
// (1) Set on the root — everything inheriting it changes.
document.documentElement.style.setProperty('--brand', '#c2185b');

// (2) Read the computed value. Note it comes back as a string, with whitespace.
const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand').trim();

Line (1) is how runtime theming, user-chosen accent colours and dark mode toggles are implemented without swapping stylesheets. Line (2) reads it back; the .trim() is necessary because the value is returned exactly as written, leading space included.

They cascade like any other property, so the whole of Chapter 6.2.2 applies. A media query or a :hover can change one, and everything downstream follows.

They are substituted at computed-value time, and that has one sharp consequence. The value is a token stream that is not validated when you declare it — only when it is substituted. So this fails in a specific way:

css
.thing {
  --size: 20;              /* (1) no unit — this is just the token "20" */
  width: var(--size)px;    /* (2) does NOT become 20px. Invalid. */
  width: calc(var(--size) * 1px);   /* (3) this is the correct way */
}

Line (2) does not work because substitution happens after the parser has already decided what var(--size)px looks like, and it does not concatenate into a length. Line (3) multiplies by a unit inside calc, which does.

When a substitution produces an invalid value, the result is not the usual "ignore the declaration". The property becomes invalid at computed-value time, which means it takes its inherited value if it inherits, or its initial value otherwise. So a typo in a custom property can turn color into a colour inherited from four ancestors up, rather than leaving the previous declaration in place. It is a genuinely confusing failure mode and worth recognising.

Fallbacks are the second argument, and everything after the first comma is the fallback, commas included:

css
color: var(--ink, #111);
font-family: var(--font, system-ui, sans-serif);

@property, which fixes the two remaining gaps

css
@property --brand-hue {
  syntax: '<number>';       /* (1) it is a number, not an arbitrary token */
  inherits: true;           /* (2) opt out of inheritance if you want */
  initial-value: 214;       /* (3) a real default, so var() never falls back */
}

Line (1) is the important one. A registered custom property has a type, and a typed property can be animated and transitioned — an ordinary custom property cannot, because the browser has no idea how to interpolate between two token streams. Registering it as <number>, <color> or <length> makes gradient and colour animations possible that were impossible before.

Line (2) lets you turn inheritance off, which is useful for a property that should only apply where it is set. Line (3) gives it a genuine initial value, so an unset property is well-defined instead of invalid.

One performance note. Changing a custom property high in the tree invalidates style for everything that reads it, which on a large page is real work on the main thread (Chapter 6.1.2's style step). For a theme toggle that happens once, this is irrelevant. For something updated on every pointer move — a spotlight effect following the cursor — set the property on the smallest element that needs it rather than on :root.

What the interviewer will push on

"What does box-sizing: border-box change?" width starts meaning the whole visible box rather than the content alone, so padding and border come out of the inside. Then note that margin is outside either way. The history — that the "wrong" Internet Explorer behaviour turned out to be the useful one — is a good extra if the conversation allows it.

"Why is there a gap under my image?" It is inline, so it sits on a text baseline and the line box reserves descender space. display: block or vertical-align: bottom. Candidates who say "add font-size: 0" have found a workaround without knowing the cause.

"Explain margin collapsing and how to stop it." Adjacent vertical margins take the larger value; it happens between siblings and between a parent and its first or last child, and never in flex or grid. Stop it with padding, a border, or display: flow-root. Volunteer that the modern approach is to use gap on the container and stop putting margins on components at all.

"rem or px for font size?" rem, because a user's browser font-size setting changes the root and px ignores it. That is an accessibility answer, not a style preference, and it is the answer being looked for.

"Why is 100vh wrong on mobile?" vh is measured against the largest viewport, so with the toolbar showing, the bottom of a 100vh section is off screen. Use svh when something must fit, dvh when you want it to follow the toolbar.

"How are CSS custom properties different from Sass variables?" They are inherited, live at runtime, cascade, and can be read and written from JavaScript. A Sass variable is gone before the browser sees the file. The follow-up worth pre-empting: var(--x)px does not work, calc(var(--x) * 1px) does.

One thing to volunteer: explain what happens when a var() substitution is invalid — the property becomes invalid at computed-value time and falls back to the inherited value rather than the previous declaration. It is the one custom-property failure that looks like the cascade has broken, and knowing it marks you as someone who has debugged a design system rather than only consumed one.

Recall

  • content-box (the default) makes width size the content alone, so padding and border add on top; border-box makes width the whole visible box. Margin is outside either way. The global *, *::before, *::after { box-sizing: border-box } is standard practice.
  • Vertical margins collapse — between siblings, and between a parent and its first or last child, which is the surprising one. Never in flex or grid. Stopped by padding, a border, or display: flow-root (the clean option; overflow: hidden also clips).
  • An <img> is inline and sits on a baseline, so a line box reserves descender space beneath it — that is the mystery gap. display: block or vertical-align: bottom.
  • em compounds when used for font-size (it means the parent's size there); rem does not. Use rem because a user's browser font setting changes the root, and use em inside a component so padding scales with its own text.
  • 100vh measures the largest viewport, so it overflows on mobile with toolbars showing. svh always fits, lvh is the old behaviour, dvh follows the toolbars.
  • min() imposes a maximum and max() imposes a minimum. clamp(min, preferred, max) gives fluid type in one line — keep a rem term in the middle so browser zoom still works.
  • Custom properties are inherited, live at runtime, and cascade, which is why .dark-panel { --surface: … } re-themes a subtree with no extra selectors. Sass variables cannot do any of that.
  • var(--x)px is invalid; use calc(var(--x) * 1px). A failed substitution is invalid at computed-value time and falls back to the inherited value, not to the previous declaration.
  • @property gives a custom property a type, which is what makes it animatable, plus explicit inheritance and a real initial value.

Self-test: Why do two 300px columns not fit in 600px by default? · Where does a child's margin-top go when nothing blocks collapsing? · Why does a <span> ignore height? · Why is rem an accessibility decision? · What does min(1200px, 100%) guarantee? · What happens when var() substitutes something invalid?

Next: 6.2.4 takes these boxes and arranges them — the two layout systems that replaced floats, the three-number shorthand almost nobody reads correctly, and how to choose between them without guessing.