Master the principles of typeface pairing — contrast, classification, historical period, and shared DNA — and implement every pairing decision in CSS with correct loading and fallbacks.
Most web projects use two typefaces: one for display/headings and one for body text. The difference between a pairing that feels professional and one that feels clunky is not luck — it follows learnable principles. This lesson gives you a framework for making pairing decisions confidently, plus the CSS to implement them cleanly.
Contrast: the two typefaces should be clearly different — different classification (serif vs sans), different weight range, or different personality. Typefaces that are similar but not identical create visual tension, not harmony. The risk: the viewer cannot tell if the variation is intentional or a mistake. Similarity within contrast: despite their contrast, the typefaces should share some underlying characteristic — similar x-height, similar optical weight at their respective sizes, similar historical period. A Renaissance humanist serif (Garamond) pairs beautifully with a humanist sans (Gill Sans) because both derive from the same calligraphic tradition. A geometric sans (Futura) pairs awkwardly with a humanist serif because their DNA is incompatible. Hierarchy clarity: each typeface should have a clear role. Display typeface for headings, decorative use, and brand expressions. Body typeface for reading, UI text, and everything else. A third typeface is rarely needed; if you have code, monospace is a functional category, not a brand choice. Economy: fewer typefaces loaded means faster pages and more design cohesion. Two is almost always enough; three is the maximum for most projects.
Pattern 1 — Serif display + humanist sans body: the serif provides warmth and brand character at headings; the sans provides clarity and neutrality for body text. Classic examples: Playfair Display + Inter, Lora + Source Sans, Fraunces + Work Sans. Works because the serif's personality is contained to headings; the body remains scannable. Pattern 2 — Single variable font, two weights: use one variable font family across all text. Headings at weight 800, body at weight 400, UI at weight 500. Zero pairing conflict since it is literally the same typeface. Examples: Inter Variable, Recursive, Roboto Flex. Pattern 3 — Super-family: some type designers create matched serif and sans variants sharing the same proportions and metrics. Lucida Serif + Lucida Sans, FF Meta + FF Meta Serif, IBM Plex Serif + IBM Plex Sans. These are designed to pair perfectly because they are mathematically coordinated.
1/* Pattern 1: Serif display + Sans body */
2@font-face {
3 font-family: 'Fraunces';
4 src: url('/fonts/fraunces-var.woff2') format('woff2');
5 font-weight: 100 900;
6 font-style: normal;
7 font-display: swap;
8}
9@font-face {
10 font-family: 'Work Sans';
11 src: url('/fonts/worksans-var.woff2') format('woff2');
12 font-weight: 100 900;
13 font-style: normal;
14 font-display: swap;
15}
16
17:root {
18 --font-display: 'Fraunces', Georgia, serif;
19 --font-body: 'Work Sans', system-ui, sans-serif;
20}
21
22/* Pattern 3: IBM Plex super-family */
23:root {
24 --font-display: 'IBM Plex Serif', Georgia, serif;
25 --font-body: 'IBM Plex Sans', system-ui, sans-serif;
26 --font-code: 'IBM Plex Mono', monospace;
27}Define font roles as CSS custom properties and use those roles — never hardcode font names at the point of use. This lets you swap the entire type system by changing two lines in :root rather than hunting through 200 CSS rules. The three standard roles for most web projects: --font-display for headings and expressive text, --font-body for all readable text, --font-code for code blocks and monospace contexts. On design tokens that extend this: --font-ui for UI labels and controls when they differ from reading text.
1/* Font role tokens — single source of truth */
2:root {
3 --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;
4 --font-body: 'Inter', system-ui, -apple-system, sans-serif;
5 --font-code: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
6 --font-ui: var(--font-body); /* Same as body unless overridden */
7}
8
9/* Apply roles semantically */
10body { font-family: var(--font-body); }
11h1, h2, h3,
12.hero-title,
13.display-text { font-family: var(--font-display); }
14code, pre, kbd { font-family: var(--font-code); }
15button, input,
16label, select { font-family: var(--font-ui); }
17
18/* Swap entire type system: change two lines */
19/* :root { --font-display: 'Playfair Display', ...; */
20/* --font-body: 'Source Sans 3', ...; } */A two-font system with variable fonts can be loaded cleanly with two @font-face blocks and two preload hints. Preload only the body font (critical — affects first-paint content) or the display font if it appears above the fold in the hero. Using font-display: optional on the display font prevents a jarring late swap on the headline, since the design will read fine in the fallback serif for the brief period before the custom font loads.
1<!-- Preload body font only — it affects the most content -->
2<link rel="preload" href="/fonts/inter-var.woff2"
3 as="font" type="font/woff2" crossorigin />
4<!-- Display font: optional — avoids late headline swap -->1/* Body: swap — show content immediately */
2@font-face {
3 font-family: 'Inter';
4 src: url('/fonts/inter-var.woff2') format('woff2');
5 font-weight: 100 900;
6 font-style: normal;
7 font-display: swap;
8}
9
10/* Display: optional — use fallback if font is not cached */
11@font-face {
12 font-family: 'Fraunces';
13 src: url('/fonts/fraunces-var.woff2') format('woff2');
14 font-weight: 100 900;
15 font-style: normal;
16 font-display: optional;
17}
18
19/* Total: 2 WOFF2 requests vs 8+ for equivalent static fonts */Common bad pairings and why they fail: Comic Sans + Times New Roman — violates contrast principle (both are decorative in their own way, neither has a clear display vs body role); Helvetica + Arial — violates contrast principle (near-identical typefaces create visual noise, not hierarchy); Futura + Garamond — violates similarity principle (geometric vs calligraphic DNA are incompatible; they fight rather than complement); three sans-serif fonts — violates economy and role clarity (without a serif-versus-sans contrast, the roles blur). Diagnosis checklist: (1) Is there clear contrast between the two typefaces? (2) Do they share any underlying characteristic? (3) Does each have an unambiguous role? (4) At the sizes you actually use them, do they look intentionally paired or accidentally similar?
1/* Diagnosing a pairing: test at actual use sizes */
2.pairing-test {
3 display: grid;
4 gap: 2rem;
5 padding: 2rem;
6 max-width: 600px;
7}
8
9.test-heading {
10 font-family: var(--font-display);
11 font-size: 2.5rem;
12 font-weight: 700;
13 line-height: 1.1;
14}
15
16.test-body {
17 font-family: var(--font-body);
18 font-size: 1rem;
19 line-height: 1.65;
20}
21
22/* Checklist: view this at actual sizes, not in a font preview tool */
23/* Ask: does the heading font feel related to the body font? */
24/* Ask: is the contrast clearly intentional, not accidental? */Complete setup for a serif display + humanist sans body pairing with preloading and role tokens.
Next lesson covers dark mode typography — contrast recalculation, antialiasing behaviour differences, and how to adjust weight and spacing for optimal readability on dark backgrounds.
Your progress is saved in this browser, on this device only.