12 KiB
Typography — The Design
Typography does 80% of the work. Choose faces with care, set them with intention, and never let defaults decide for you.
The Two-Typeface Rule
Pick one display face and one text face. Two total. Mono can be a third if needed (numbers, code, kickers).
Why: A page with three different typefaces reads as confused. A page with one great family used well reads as designed.
How to choose
Display face — used in headlines, hero, section markers, big moments.
- Ask: does it have character? Would I recognize it on a poster?
- Avoid: anything that looks like default system fonts. Roboto, Open Sans, Lato — these are fine but not chosen.
- Test: render the brand name in the display face at 96px. Does it look like a magazine? A poster? An interface? Good. If it looks like a template — pick another.
Text face — used in body, UI, forms, captions.
- Ask: is it legible at 14–16px for sustained reading?
- Ask: does it have a complete weight range (400, 500, 600, 700) and a good italic?
- Avoid: thin weights under 400 for body text. Avoid display serifs as body.
Mono face (optional) — for numbers, code, kickers, metadata.
- Must have good tabular figures (numbers align in tables).
- Use for: pricing, statistics, code, timestamps, IDs, environment variables.
Recommended Faces (free / open license where possible)
Sans (modern grotesque / neo-grotesque)
- Inter — workhorse, free, full range
- Söhne — Linear/Stripe-quality, paid
- Geist — Vercel's, free, beautiful
- GT America — paid, super versatile
- ABC Diatype — paid, elegant
- Helvetica Now — paid, the modern Helvetica
- Neue Haas Grotesk — paid, the original spirit
- IBM Plex Sans — free, distinctive
Serif (display)
- GT Super — paid, warm, magazine
- Tiempos Headline — paid, editorial
- Söhne Serif — paid, modern serif
- Editorial New — paid, newspaper
- Domaine Display — paid, NYT-class
- GT Sectra — paid, contemporary serif
- Lora / Source Serif / Newsreader — free, good
- Fraunces — free, expressive, quirky
- Instrument Serif — free, elegant display
- Playfair Display — free, classic, use sparingly
Mono
- JetBrains Mono — free, dev-friendly
- IBM Plex Mono — free, well-balanced
- Berkeley Mono — paid, the gold standard
- GT America Mono — paid
- Geist Mono — free, Vercel
- Iosevka — free, condensed
- Fragment Mono — free, modern
Pairs That Work
| Display | Text | When |
|---|---|---|
| Söhne / Inter Display | Söhne / Inter | Refined Minimal, SaaS |
| GT Super / Fraunces | Inter / Söhne | Editorial, magazine |
| GT America | GT America Mono | Refined Minimal, technical |
| Helvetica Now | Helvetica Now | Swiss, manifestos |
| Geist | Geist Mono | Technical, dev tools |
| IBM Plex Sans | IBM Plex Mono | Technical, docs |
| Instrument Serif | Inter | Editorial, soft premium |
| Editorial New / Tiempos | Inter | Publishing, journalism |
| GT Sectra | ABC Diatype | Editorial premium |
| Manrope / Inter | JetBrains Mono | Playful, modern SaaS |
Pairs that almost never work
- ❌ Two different serifs (one display, one text)
- ❌ Two different sans-serifs from different schools (e.g., a humanist + a geometric)
- ❌ Display serif + heavy industrial sans
- ❌ Comic Sans + anything
- ❌ Script + anything (only for one-off flourishes, never headlines)
Scale & Sizes
Default modular scale: 1.250 (Major Third) — comfortable for product UI. Editorial scale: 1.333 (Perfect Fourth) or hand-tuned — for content-heavy pages.
Suggested scale (px, base 16px, ratio 1.250)
| Token | Size | Use |
|---|---|---|
text-xs |
12px | Captions, labels, microcopy |
text-sm |
14px | UI secondary, table cells, footnotes |
text-base |
16px | Body, paragraphs, inputs |
text-lg |
20px | Lead paragraphs, large UI |
text-xl |
25px | H4, subhead small |
text-2xl |
31px | H3, subhead medium |
text-3xl |
39px | H2 |
text-4xl |
49px | H1, section markers |
text-5xl |
61px | Large section H1 |
text-6xl |
76px | Page hero (small) |
text-7xl |
95px | Page hero (medium) |
text-8xl |
119px | Page hero (large) |
text-9xl |
149px | Editorial hero, posters |
Hero size — choose deliberately
- Confident / minimal:
clamp(2.5rem, 5vw, 4rem)— 40–64px - Strong:
clamp(3.5rem, 6vw, 5.5rem)— 56–88px - Bold / editorial:
clamp(4rem, 8vw, 7rem)— 64–112px - Magazine / poster:
clamp(5rem, 10vw, 10rem)— 80–160px - Always test: if hero is set at the default 36–48px, it reads as a template. Push it.
CSS clamp formula
font-size: clamp(<min>, <fluid>, <max>);
Example:
font-size: clamp(2.25rem, 5vw + 1rem, 4.5rem);
The fluid value uses vw so it scales with viewport, with the + rem offset so it doesn't get tiny on small screens.
Line Height (leading)
| Type | Line height |
|---|---|
| Display headlines (set tight) | 1.0 – 1.1 |
| Large H1 / H2 (60px+) | 1.05 – 1.15 |
| Standard headings (24–40px) | 1.15 – 1.3 |
| Lead paragraphs (18–22px) | 1.4 – 1.5 |
| Body copy (16–18px) | 1.5 – 1.65 |
| Small body / UI secondary (14px) | 1.45 – 1.55 |
| Captions / labels (12–13px) | 1.4 – 1.5 |
Rule: bigger the type → tighter the leading. Smaller the type → looser the leading. UI = around 1.4–1.5.
Letter Spacing (tracking)
| Type | Tracking |
|---|---|
| Display headlines (large) | -0.02em to -0.04em (negative — pulls letters closer) |
| Standard headings | -0.01em to -0.02em |
| Body copy | 0 (default) |
| All-caps labels / kickers | +0.05em to +0.12em (positive — opens up) |
| Buttons (often all-caps small) | +0.02em to +0.05em |
| Numerical mono data | 0 (let the mono handle alignment) |
Rule: larger display type wants negative tracking. All-caps wants positive tracking. Body copy wants 0.
Weights — Use With Restraint
A typeface has 4–9 weights. Use 2–3 max per page. Here is the typical allocation:
- 400 (Regular) — body copy, paragraphs, default UI
- 500 (Medium) — buttons, labels, emphasized inline text, captions
- 600 (Semibold) — subheads, H3/H4, important UI
- 700 (Bold) — H1/H2, hero, key moments only
Avoid: 300 (Light) for body. Avoid 800/900 unless it's a display moment — and even then, only if the family is designed for it.
When to bold, when to italic
- Bold for hierarchy. Italic for tone, foreign words, citations.
- Italic in body: titles of works, the New York Times, foreign phrases, internal thought.
- Bold in body: sparingly — for inline emphasis. Don't bold entire sentences; bold the word.
- Display italic: some serifs have a beautiful italic — use it for editorial pull quotes, byline accents.
Color & Contrast for Type
- Primary text: ink color on surface. Contrast ratio ≥ 7:1 (AAA) where possible. ≥ 4.5:1 (AA) at minimum for body.
- Secondary text: muted ink. Contrast ratio ≥ 4.5:1 minimum.
- Tertiary / placeholders: even more muted — acceptable to dip to 3:1 for non-essential.
- Never: light gray (#999) on white for body. Use #6B6B6B at lightest.
- Headlines: can dip lower contrast (3.5:1+) for stylistic effect — but never for body.
- Links: color or underline, not just color (accessibility).
- Focus state: visible focus ring, 2px offset, accent color.
See color.md for palette construction.
Special Treatments
Drop caps
- Use only in long-form articles, editorial spreads
- 3–4 lines tall, set in display face
- Indent the rest of the paragraph
Pull quotes
- Display face, 1.5–2x body size
- Left-aligned, often with rule lines
- Sometimes quote marks in a much larger size (decorative)
Numerals
- Use tabular figures (
font-variant-numeric: tabular-nums) for tables, pricing, statistics - Use lining figures (default in most fonts) for headlines and prose
- Old-style figures (with descenders) are a beautiful editorial choice — use consistently
Hyphenation & justification
- Left-align body. Never justify body text — it creates ugly rivers.
- Use
hyphens: autosparingly; better to enable it for narrow columns, disable for wide ones - Use
text-wrap: pretty(modern CSS) when available — improves line breaks
All-caps
- For kickers, labels, navigation, small UI elements
- Always positive tracking (+0.05em+)
- Never for body. Never for headlines over 24px (reads as shouting).
Underlines
- Default browser underlines on links are ugly. Replace with custom underlines:
text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px;
- Or use a
border-bottomon inline elements for more control
Typography Anti-Patterns
| ❌ Don't | ✅ Do |
|---|---|
font-weight: 700 on every heading regardless of family |
Use 600 for headings, 700 only for hero moments |
Letter-spacing 0 on all-caps labels |
Add +0.05em to +0.12em to all-caps |
Default browser font stack (-apple-system, sans-serif) |
Choose a face. Even Inter is a choice. |
| Two different type families from different schools | Stick to ONE family for display + text |
| Body text in a display serif | Use display serif for display only |
| Justified text in a narrow column | Left-align, ragged right |
font-size: 16px hero headlines |
Hero should be 60–160px |
Heading set with line-height: 1.5 (looks loose) |
Tighten to 1.05–1.15 on display |
Letter-spacing -0.05em on body text (cramped) |
Use -0.02em max for body, more for display |
Inline style="font-size: ..." everywhere |
Define a scale in tokens, use them |
| Mixing px and rem inconsistently | Use rem everywhere (or use a token system) |
Setting font-size on <p> manually |
Let the base size + scale handle it |
| Italic body copy in a font with no italic (auto-faked) | Pick a face with a real italic |
A Working CSS Setup
:root {
/* Type tokens */
--font-display: 'GT Super', 'Tiempos', Georgia, serif;
--font-text: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Scale (1.250) */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.25rem; /* 20px */
--text-xl: 1.5625rem; /* 25px */
--text-2xl: 1.953rem; /* 31px */
--text-3xl: 2.441rem; /* 39px */
--text-4xl: 3.052rem; /* 49px */
--text-5xl: 3.815rem; /* 61px */
--text-6xl: 4.768rem; /* 76px */
--text-7xl: 5.96rem; /* 95px */
/* Leading */
--leading-tight: 1.05;
--leading-snug: 1.2;
--leading-normal: 1.5;
--leading-loose: 1.65;
/* Tracking */
--tracking-tightest: -0.04em;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.05em;
--tracking-widest: 0.12em;
}
body {
font-family: var(--font-text);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--ink);
background: var(--surface);
font-feature-settings: 'kern' 1, 'liga' 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
font-weight: 600;
}
h1 {
font-size: clamp(3.5rem, 6vw + 1rem, 6rem);
}
h2 {
font-size: clamp(2.25rem, 4vw, 3.5rem);
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-widest);
color: var(--ink-muted);
}
.measure {
max-width: 65ch; /* reading measure */
}
Loading Fonts
- Self-host when possible (privacy, performance, no FOUT).
- Subset to Latin (or relevant script). Don't load 9 weights of 9 fonts.
- Preload the display face used above the fold.
- Use
font-display: swapto avoid invisible text. - Variable fonts when available — one file, full weight range.
- Fallback metrics — set
size-adjust,ascent-override,descent-overrideon fallback to minimize layout shift.
<link rel="preload" href="/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin>
@font-face {
font-family: 'Inter';
src: url('/fonts/InterVariable.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}