Typography, fonts and fluid type

Font stacks and web fonts, line-height and measure, text wrapping, and type that scales with clamp instead of breakpoints.

Stacks, web fonts and loading

Typography is most of what people read on a page, and most of its cost is bytes. A system font stack costs nothing and renders instantly; a custom web font costs a request and a moment of layout risk.

:root {
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
}

body { font-family: var(--font-sans); }

@font-face {
  font-family: "Brand";
  src: url("/fonts/brand.woff2") format("woff2");
  font-display: swap;
  unicode-range: U+0000-00FF;
}
  • Ship WOFF2 only — every browser that matters supports it, so older formats are dead weight.
  • font-display: swap shows fallback text immediately and swaps when the font arrives, instead of leaving the page blank.
  • unicode-range splits a font into subsets so a Latin-only page never downloads Cyrillic glyphs.
  • Preload the one font used above the fold with <link rel="preload" as="font" crossorigin>; preloading everything competes with the critical CSS.
  • Always end a stack with a generic family so the browser has a guaranteed fallback.

Line-height, measure and wrapping

Readable text depends on three numbers rather than the typeface itself: the size contrast between levels, the space between lines, and the length of a line of text.

.prose {
  font-size: 1.125rem;    /* 18px when the root is 16px */
  line-height: 1.6;       /* unitless: multiplied by each element's own size */
  max-inline-size: 68ch;  /* the measure: roughly 60 to 75 characters */
}

h2 { text-wrap: balance; }
p  { text-wrap: pretty; }
td { font-variant-numeric: tabular-nums; }
PropertyEffectWatch out for
line-height unitlessScales with each element's font sizeA unit such as px freezes it and breaks inheritance
max-inline-sizeCaps the line lengthUse ch, not px, so it tracks the font
text-wrap: balanceEvens out heading linesBrowser-limited to a handful of lines — headings only
text-wrap: prettyAvoids single-word last lines in proseA refinement, never a requirement
tabular-numsFixed-width digitsOnly for tables and counters, not body text
letter-spacingTightens large text, loosens small capsNever letter-space lowercase body copy

Fluid type with clamp

clamp(min, preferred, max) interpolates between two sizes across a range of viewport widths, which removes a whole class of breakpoints. The preferred value is normally a rem base plus a vw slope.

h1    { font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem); }
.lede { font-size: clamp(1rem, 0.95rem + 0.4vw, 1.25rem); }
h2    { font-size: clamp(1.35rem, 1.1rem + 1.1vw, 1.9rem); }

Keep the middle term in rem plus vw rather than pure vw: a pure viewport unit ignores the user's own font-size preference, which is exactly the accessibility setting fluid type is supposed to respect.

  • Build a small scale — a 1.2 or 1.25 ratio between steps is plenty for most sites.
  • Clamp heading sizes aggressively and body sizes gently; body text should barely move.
  • Check 320px and a very wide monitor: those two extremes are where a bad clamp shows up.
  • Pair fluid type with fluid spacing so the rhythm does not become lopsided at small sizes.
⚠️
Never disable zoom with user-scalable=no or a maximum-scale of 1 in the viewport meta tag. Fluid type reduces how often people need to zoom; it is not a substitute for zoom.

FAQ

Should font sizes be in rem or px?
Use rem for anything the reader can zoom or that should respect a changed browser default, which means all type and most spacing. Reserve px for hairlines such as 1px borders.
Why does my web font flash or jump?
The fallback is rendering first and the metrics of the two fonts differ. font-display: swap makes the flash deliberate rather than invisible, and size-adjusted fallback metrics reduce the jump.

Units, custom properties and calc Color, backgrounds and dark mode

Last refreshed 2026-09-18.