Why typography matters
Typography sets the voice of a product. Good type makes content clear, scannable, and trustworthy. Bad type confuses users, creates visual noise, and hurts accessibility.
Font choices
- System stack — fastest, no downloads:
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - Variable fonts — one file, multiple weights and optical sizes. Prefer ranges like
wght 300–800, and useopszif available. - Always check language support (diacritics, ligatures) and license terms for web embedding.
Scale and hierarchy
Use 6–8 steps, not 20. Keep them fluid with clamp(). Base: 16px, ratio ~1.2 for content-heavy apps.
:root{
--fs-xs: clamp(.78rem, .74rem + .20vw, .88rem);
--fs-sm: clamp(.88rem, .84rem + .30vw, .95rem);
--fs-base: 1rem;
--fs-h6: clamp(1.1rem,1.03rem + .5vw,1.25rem);
--fs-h5: clamp(1.35rem,1.2rem + 1vw,1.56rem);
--fs-h4: clamp(1.62rem,1.35rem + 1.8vw,1.95rem);
--fs-h3: clamp(1.95rem,1.6rem + 2.6vw,2.44rem);
--fs-h2: clamp(2.44rem,2rem + 3.6vw,3.05rem);
--fs-h1: clamp(3.05rem,2.4rem + 5vw,3.81rem);
}
body{ font-size: var(--fs-base); }
h1{ font-size: var(--fs-h1); }
h2{ font-size: var(--fs-h2); }
h3{ font-size: var(--fs-h3); }
Line-height & vertical rhythm
- Body text: 1.45–1.6
- Headings: 1.15–1.25
- Use spacing tokens instead of arbitrary margins.
:root{
--lh-tight: 1.18; --lh-text: 1.55;
--space-2: .5rem; --space-4: 1rem; --space-6: 1.5rem;
}
p{ line-height: var(--lh-text); margin: 0 0 var(--space-4); }
h1,h2,h3{ line-height: var(--lh-tight); margin: 0 0 var(--space-6); }
Measure (line length)
Optimal readability: 60–75 characters per line. On small screens, width shrinks naturally.
.prose{ max-width: 72ch; }
.prose img, .prose pre{ max-width: 100%; }
Contrast & accessibility
- Minimum contrast: WCAG AA (4.5:1 for body, 3:1 for headings).
- Avoid text on noisy images/gradients unless you add an overlay (“scrim”).
- Use
remunits, respect user zoom.
Tracking & ligatures
- Large display headings: slight negative tracking (
-0.01em). - Labels or all-caps: positive tracking (
.02–.04em). - Code blocks: disable fancy ligatures if distracting.
h1{ letter-spacing: -.012em; }
.kicker{ text-transform: uppercase; letter-spacing: .04em; font-size: var(--fs-sm); }
code{ font-variant-ligatures: none; }
Font loading (performance)
- Preload critical weights, use
font-display: swap. - Override font metrics to avoid CLS jumps.
@font-face{
font-family: 'YourVF';
src: url('/fonts/YourVF.woff2') format('woff2-variations');
font-weight: 300 800;
font-display: swap;
ascent-override: 90%; descent-override: 22%; line-gap-override: 0%;
}
Semantic tokens
Instead of “h2 = 2rem”, define semantic roles. Easier to theme and adjust later.
:root{
--fs-body: var(--fs-base);
--fs-lead: var(--fs-h5);
--fs-kicker: var(--fs-sm);
--fs-quote: var(--fs-h4);
}
.lead{ font-size: var(--fs-lead); line-height: 1.45; }
blockquote{ font-size: var(--fs-quote); line-height: 1.35; }
Quick checklist
6–8 step fluid scale with
clamp()
Base text 16–18px, body LH 1.45–1.6
Measure: 60–75ch
Contrast ≥ WCAG AA
Preload fonts + font-display: swap + metrics override
Semantic tokens: body, lead, kicker, caption, code