Typography and web fonts
Most of a web page is text, so most of design is typography. Five properties do nearly all the work, and the one genuinely consequential decision is whether to load a font at all.
The five that matter
body {
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.6;
font-weight: 400;
letter-spacing: 0;
}
line-height, which is the one people get wrong
line-height: 1.6; /* unitless — 1.6 × this element's font size */
line-height: 24px; /* fixed */
line-height: 150%; /* computed once, then inherited as a length */
Always unitless. The reason is inheritance: a unitless value is inherited as a
ratio and recalculated per element, while 24px or 150% is computed on the
parent and inherited as a fixed length. So with line-height: 150% on body, your
h1 inherits 24px of line height for 40px text and the lines overlap.
Useful values: 1.5 to 1.7 for body text — WCAG asks for at least 1.5 — and 1.1 to 1.25 for large headings, because big text needs proportionally less space. A heading at 1.6 looks like it is falling apart.
body { line-height: 1.6; }
h1, h2, h3 { line-height: 1.2; }
Line length, which nobody teaches and everybody notices
A comfortable measure is 45 to 75 characters. Longer and the eye loses its place returning to the left; shorter and it jumps too often.
The ch unit makes this direct:
.prose {
max-width: 65ch;
}
1ch is the width of a 0 in the current font, so 65ch is roughly 65 characters.
This single declaration does more for readability than any font choice, and a
full-width paragraph on a 1440px monitor is the most common design mistake on the
web.
font-weight
font-weight: 400; /* normal */
font-weight: 700; /* bold */
font-weight: 600; /* semibold */
Numbers, not bold, because they are unambiguous. A weight only renders if the
font has it. Ask for 600 from a font with only 400 and 700 and the browser
synthesises it by smearing the 400 — which looks slightly wrong in a way that is
hard to name. Check which weights you actually loaded.
letter-spacing
Leave it at 0 for body text; the font designer already chose it. Two cases where it
helps: large headings, which often want slightly tighter (-0.02em), and
all-capitals text, which always wants looser (0.05em) because capitals were not
designed to sit together.
Use em, not px, so it scales with the font size.
Font stacks, and the free option
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
system-ui is the operating system's own interface font — San Francisco on
iOS and macOS, Roboto on Android, Segoe UI on Windows. It is already on the
device: zero bytes, zero requests, renders on the first paint.
For a small business site this is very often the right answer, and it is the default this course recommends. The page looks native, it is instant even on a bad connection, and you have spent none of your visitor's data.
The honest counter-argument: a brand may need a specific typeface, and system-ui
means your site looks slightly different on every platform. If that matters, load a
font — knowing what it costs.
Loading a font, and what it costs
@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2-variations");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+20B9;
}
Six things in there, and each is a decision.
woff2 only. It is compressed, supported by every browser you care about, and
roughly 30% smaller than woff. The long src lists with eot and ttf you will
find in older tutorials are for Internet Explorer.
Self-host. Do not link to Google Fonts' CDN. It is an extra DNS lookup and
connection before the font is even requested, the shared-cache benefit stopped
existing when browsers partitioned caches by site, and in several jurisdictions it
is a privacy problem because the visitor's IP goes to a third party. Download the
files into /fonts/ and serve them yourself — it is faster and simpler.
A variable font gives you every weight from 100 to 900 in one file, usually
smaller than three static weights. font-weight: 100 900 declares the range.
font-display: swap is the important one. Without it, the browser hides the text
for up to three seconds waiting for the font — a blank page with a working layout,
which is worse than any font substitution. swap shows the fallback immediately and
swaps when the font arrives.
The cost of swap is a visible reflow when the swap happens, because the fallback
has different metrics. You reduce it by choosing a fallback with similar
proportions, and by:
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
size-adjust: 107%; /* match the fallback's x-height */
ascent-override: 90%;
descent-override: 22%;
}
Those three override the font's own metrics so the fallback and the real font occupy the same space, and the swap stops moving the page. This is what "zero layout shift font loading" means, and it is worth doing for the one font on your critical path.
unicode-range tells the browser which characters the file covers, so it only
downloads what the page needs. Note U+20B9 — that is the rupee sign, and a Latin
subset that omits it will fall back to a different font for ₹ alone, which looks
visibly wrong on an Indian price list. Check it.
Preload the one font you need first:
<link rel="preload" href="/fonts/inter-variable.woff2" as="font"
type="font/woff2" crossorigin>
crossorigin is required even for a same-origin font, and leaving it off means the
font is downloaded twice. That is a genuinely common mistake.
Preload at most one or two files. Preloading everything makes the preloads compete with each other and with your CSS.
How much is too much
One variable font, Latin-subset, is roughly 25–40 KB. Three static weights of a static font is 60–100 KB. Two families at three weights each is 200 KB, which on a slow connection is a second of blank-or-wrong text before anything settles.
Budget one family, two weights. If you need a display face for headings, that is
the second family, and body text should then be system-ui.
Practical text properties
text-align: left; /* start/end are the logical versions */
text-transform: uppercase;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.2em;
text-wrap: balance; /* headings: even line lengths */
text-wrap: pretty; /* paragraphs: avoids single-word last lines */
hyphens: auto;
overflow-wrap: break-word;
font-variant-numeric: tabular-nums;
Three of these are worth adopting immediately.
text-wrap: balance on headings. A two-line heading with one word on the second
line looks like a mistake; balance evens the lines out. Apply it only to short
text — it is limited to a handful of lines by design.
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }
tabular-nums for any column of numbers. By default many fonts give digits
different widths, so a column of prices does not line up. This makes every digit the
same width. Essential in a price table.
Never text-align: justify on the web. Without proper hyphenation and
justification algorithms it produces rivers of white space, and it is measurably
harder to read. Print can justify; browsers cannot, well.
And text-transform: uppercase changes only the rendering — a screen reader reads
the original text, which is correct and is why you should not type in capitals to
get the effect.
A type scale
Do not pick sizes at random. Pick a ratio and multiply:
:root {
--step-0: 1rem; /* 16px body */
--step-1: 1.25rem; /* 20px */
--step-2: 1.563rem; /* 25px */
--step-3: 1.953rem; /* 31px */
--step-4: 2.441rem; /* 39px */
}
That is a 1.25 ratio. Common choices are 1.2 (subtle) through 1.333 (dramatic). Five steps is plenty for a small site, and having a fixed set stops the "is this 22px or 24px" question entirely.
Module 4 makes these fluid with clamp() so they shrink on a phone.
The reading-comfort checklist
For body text, all five at once:
.prose {
font-size: 1rem; /* never below 16px on a phone */
line-height: 1.6;
max-width: 65ch;
color: #222; /* not #000 — pure black on white is harsh */
text-wrap: pretty;
}
Never below 16px for body text on mobile. Below that, iOS Safari zooms the page when a form field is focused, which is disorienting and looks broken. It is also simply too small.
And #222 rather than #000: pure black on pure white is uncomfortably
high-contrast for long reading. #222 on #fff is still about 15:1, far above the
4.5:1 WCAG AA requirement.
Check your work
Why line-height must be unitless. A unit is computed once and inherited as a
fixed length, so headings overlap.
Values for body and headings. 1.5–1.7, and 1.1–1.25.
The comfortable line length. 45–75 characters — max-width: 65ch.
Why numeric font-weight. Unambiguous, and a weight the font lacks is
synthesised and looks wrong.
Where letter-spacing helps. Tighter on large headings, looser on capitals.
Why system-ui is a real option. Already on the device: zero bytes, instant.
Why self-host rather than a font CDN. An extra connection, no shared-cache benefit any more, and a third party gets the visitor's IP.
What font-display: swap prevents. Up to three seconds of invisible text.
What size-adjust and the override descriptors do. Make the fallback occupy the
same space, so the swap does not move the page.
Why unicode-range needs checking. A Latin subset without U+20B9 renders ₹
in a different font.
What happens without crossorigin on a font preload. It downloads twice.
What tabular-nums fixes. Digits of different widths, so price columns do not
align.
Why not justify. Rivers of white space; browsers do not hyphenate well enough.
Why body text is never below 16px on mobile. iOS Safari zooms on focus.
Why #222 rather than #000. Pure black on white is harsh for long reading, and
#222 is still ~15:1.
Practice
- Set
line-height: 150%onbodyand look at anh1. Change to1.6. - Set
line-height: 1.6on headings, then1.2. Compare a two-line heading. - Put a long paragraph at full width on a wide monitor, then
max-width: 65ch. - Ask for
font-weight: 600from a font with only 400 and 700. Compare with a genuine 600. - Add
letter-spacing: 0.05emto an all-caps label, then remove it. - Build a page with
system-uiand measure the total page weight. - Load a variable font, self-hosted, with
font-display: swap. Throttle to Slow 4G and watch the swap. - Remove
font-displayand reload on Slow 4G. Time how long the text is invisible. - Add
size-adjustand the override descriptors and watch whether the page still jumps on swap. - Put
₹450on a page using a Latin-subset font and check in devtools which font rendered the₹. - Preload a font without
crossoriginand count the requests in the Network tab. - Add
text-wrap: balanceto a two-line heading. Thenprettyto a paragraph. - Build a price column with and without
tabular-nums. - Set body text to 15px and focus a form field on a real iPhone.
- Compare
#000and#222body text on white for a full screen of reading. - Build the five-step scale above and use only those sizes on a page.
Official documentation
- MDN — @font-face — Every descriptor, including
size-adjustand the metric overrides. - MDN — font-display — What each value does to the invisible-text period.
- web.dev — Best practices for fonts — Preloading, subsetting and measuring the layout shift a font swap causes.
- W3C — Understanding WCAG 1.4.8 Visual presentation — The line-height and line-length requirements this lesson quotes.
Next module: layout — Flexbox and Grid.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship