The font stack
font-family is a list of preferences. The browser takes the first one it has, so the last entry must be a generic family that always exists: sans-serif, serif, monospace.
/* the system font: no download at all, and it looks native everywhere */
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* a webfont with a real fallback behind it */
h1, h2 {
font-family: "Space Grotesk", system-ui, sans-serif;
}
Quote a family name containing spaces. The fallback is not decoration — it is what people see for the first few hundred milliseconds, and what they see forever if the download fails.
Loading a webfont
- WOFF2 only. Every browser that matters supports it, and it is the smallest.
- Preload the one or two faces used above the fold:
<link rel="preload" as="font" type="font/woff2" crossorigin href="…">. crossoriginis required on font preloads even for your own domain, or the file is fetched twice.- One variable font usually replaces four static files and is smaller than two of them.
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk.woff2") format("woff2");
font-weight: 400 700; /* a variable font covers a range */
font-display: swap;
font-style: normal;
}
| font-display | What the reader sees |
|---|---|
swap | The fallback immediately, then a swap when the font lands. Nothing is ever invisible. |
optional | The fallback, and the font is used only if it arrives almost at once. No layout shift at all. |
fallback | A very short blank period, then the fallback, then a swap within a few seconds. |
block | Invisible text for up to three seconds. Almost never what you want. |
Size, and the shorthand
/* longhand */
h2 {
font-size: 1.5rem;
line-height: 1.2;
font-weight: 600;
}
/* shorthand: size and family are REQUIRED, and everything
you leave out is reset to its initial value */
h2 { font: 600 1.5rem/1.2 "Space Grotesk", sans-serif; }
/* which is why this one line is so useful on form controls */
input, button, select, textarea { font: inherit; }
Line height
Give line-height a unitless number. 1.5 means one and a half times this element's own font size, and it is recalculated for every descendant. 24px or 150% computes once on the parent and is then inherited as a fixed value, so a larger heading inside it gets the same cramped leading.
- Body text: 1.5 to 1.7.
- Large headings: 1 to 1.2 — big type needs less.
- Buttons and single-line labels: 1.
- The bigger the text, the tighter the leading. The longer the line, the looser.
Measure
Line length matters more than font size for reading comfort. Somewhere between 45 and 75 characters is the range; ch expresses it directly.
.prose p,
.prose li {
max-width: 68ch;
}
Weight and style
font-weight: 400; /* normal */
font-weight: 700; /* bold */
font-weight: 350; /* a variable font can do anything in its range */
font-style: italic;
font-variant-numeric: tabular-nums; /* digits in columns — essential in tables */
font-feature-settings: "ss01"; /* a stylistic set the font provides */
If the family has no 700 face, the browser synthesises one by smearing the 400. It looks wrong and it is a sign a weight is missing from your @font-face set.
Spacing and wrapping
letter-spacing in em scales with the font size, which is what you want. Never letter-space lower-case body text — it slows reading down.
h1 {
letter-spacing: -0.03em; /* large type needs tightening */
text-wrap: balance; /* even line lengths in a short heading */
}
p {
text-wrap: pretty; /* avoids a single word on the last line */
overflow-wrap: break-word; /* a long URL breaks instead of overflowing */
hyphens: auto; /* needs lang to be set correctly */
}
A type scale
Choosing six sizes in advance and using only those is the single biggest difference between a page that looks designed and one that does not.
:root {
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: clamp(1.25rem, 1.1rem + 0.7vw, 1.6rem);
--text-2xl: clamp(1.75rem, 1.4rem + 1.6vw, 2.6rem);
--text-3xl: clamp(2.5rem, 1.8rem + 3.4vw, 5rem);
}
Truncating
.one-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.three-lines {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
Truncated text is still in the DOM, so a screen reader reads all of it. That is usually right — but never truncate something the reader has no other way to see.