Skip to content

Typography and Web Fonts

beginner11 min read

Typography Makes or Breaks Readability

Ever opened a site that looks fine but feels off? Nine times out of ten, it's the typography. The difference between comfortable reading and eye strain comes down to font choice, sizing, spacing, and how you load those fonts.

Here's the ugly truth: font loading alone is responsible for one of the most common CLS (Cumulative Layout Shift) problems — text that reflows when the web font finally arrives, shoving your entire layout around. The tools to fix it exist, but most developers don't know they're there. Let's fix that.

Mental Model

Think of web font loading as waiting for a guest at a restaurant. font-display: swap seats the first available person (system font) immediately and swaps when the guest arrives — the table is always occupied, but there's a visible switch. font-display: optional makes a reservation but starts eating without them if they're late — no visible change. font-display: block holds the table empty for up to 3 seconds — invisible text until the font arrives.

Font Loading: FOUT, FOIT, and font-display

When a browser discovers it needs a web font, it faces a choice: show text immediately in a fallback font, or wait for the web font to download.

FOIT — Flash of Invisible Text

The browser hides text completely while the font downloads. If the font takes 3 seconds, users stare at blank space. This is the default behavior in most browsers (with a timeout of about 3 seconds before falling back).

FOUT — Flash of Unstyled Text

The browser shows text immediately in a system fallback font, then swaps when the web font loads. Users see a brief visual change, but text is always readable.

Controlling the Behavior with font-display

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately, swap when ready */
}
ValueBlock PeriodSwap PeriodBest For
auto~3s (browser decides)InfiniteAvoid — unpredictable
block3sInfiniteIcon fonts (invisible fallback looks broken)
swap~100msInfiniteBody text — always readable
fallback~100ms~3sBalance between shift and loading
optional~100msNonePerformance-critical — no swap, no CLS
Info

For most text content, font-display: swap or font-display: optional is correct. swap guarantees your web font appears eventually. optional gives the best performance — the browser uses the font only if it's already cached or downloads extremely fast. Google Fonts defaults to swap.

Eliminating Layout Shift with size-adjust

The biggest problem with font-display: swap is the layout shift when fonts swap. Different fonts have different metrics — a fallback font's characters might be wider or taller, causing text to reflow.

size-adjust lets you scale a fallback font to match the web font's metrics:

/* Step 1: Define the web font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

/* Step 2: Define a metric-matched fallback */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;       /* Scale to match Inter's character width */
  ascent-override: 90%;    /* Match Inter's ascender height */
  descent-override: 22%;   /* Match Inter's descender depth */
  line-gap-override: 0%;   /* Match Inter's line gap */
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}

When the browser shows Arial as a fallback, the size-adjust and metric overrides make it take up almost exactly the same space as Inter. When Inter loads and swaps in, there's minimal or zero layout shift.

How Next.js handles this automatically

next/font generates these metric overrides automatically. When you use import { Inter } from 'next/font/google', Next.js downloads the font at build time, calculates the exact size-adjust, ascent-override, descent-override, and line-gap-override values, and injects them into your CSS. Zero layout shift with no manual work.

Variable Fonts

Traditional fonts ship separate files for each weight and style — Regular, Bold, Italic, Bold Italic. A site using four weights loads four files.

Variable fonts pack an entire design space into one file:

@font-face {
  font-family: 'Inter Variable';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-weight: 100 900; /* Supports any weight in this range */
  font-display: swap;
}

body {
  font-family: 'Inter Variable', sans-serif;
  font-weight: 400;       /* Regular */
}

h1 {
  font-weight: 720;       /* Not just 700 — any value in the range */
}

.subtle {
  font-weight: 350;       /* Between light and regular */
}

Performance advantage: One variable font file (~100-200KB) replaces multiple static font files (often 400KB+ total). Single HTTP request, single parse.

Design advantage: You can use any weight value, not just the traditional 100/200/300/.../900 steps. Plus axes for width, optical size, slant, and custom axes.

Typography System Best Practices

Now let's put it all together into a system you can actually use.

Fluid Typography with clamp()

:root {
  --text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
  --text-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
  --text-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
  --text-lg: clamp(1.125rem, 1rem + 0.6vw, 1.25rem);
  --text-xl: clamp(1.25rem, 1rem + 1.2vw, 1.5rem);
  --text-2xl: clamp(1.5rem, 1rem + 2.4vw, 2.25rem);
  --text-3xl: clamp(1.875rem, 1rem + 4vw, 3rem);
}

Line Height, Letter Spacing, and Measure

body {
  font-size: var(--text-base);
  line-height: 1.6;        /* Unitless — scales with font-size */
  letter-spacing: -0.01em; /* Slight tightening for body text */
}

h1, h2, h3 {
  line-height: 1.2;        /* Tighter for headings */
  letter-spacing: -0.02em; /* Headings look better slightly tighter */
}

.prose {
  max-width: 65ch; /* Optimal reading measure: 45-75 characters per line */
}
Common Trap

Always use unitless line-height values (like 1.6), not units (like 24px or 1.5em). A unitless value multiplies by the element's own font-size, so it scales correctly when font-size changes. A value with units is fixed — line-height: 24px stays 24px even if font-size becomes 32px, causing text to overlap.

System Font Stacks

/* Modern system font stack — fast, no download */
body {
  font-family:
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    Oxygen-Sans,
    Ubuntu,
    Cantarell,
    'Helvetica Neue',
    sans-serif;
}

/* Simplified — system-ui keyword */
body {
  font-family: system-ui, sans-serif;
}

/* Monospace for code */
code {
  font-family:
    'JetBrains Mono',
    'Fira Code',
    ui-monospace,
    SFMono-Regular,
    Menlo,
    Monaco,
    Consolas,
    monospace;
}
Quiz
You use font-display: swap for your web font. Users report layout shift when the font loads. What is the most effective fix?
Execution Trace
Request
Browser finds font-family: 'Inter' in CSS
Checks if Inter is available locally or in cache
Download
Font not cached — fetch from server
Font file is downloading...
Block period
font-display: swap — ~100ms block
Text invisible for ~100ms
Swap period
Show 'Inter Fallback' (size-adjusted Arial)
Text visible in metric-matched fallback
Font ready
Inter downloaded — swap in
Minimal layout shift thanks to size-adjust
What developers doWhat they should do
Using font-display: block for body text
Block hides text for up to 3 seconds while the font loads. Swap shows fallback immediately.
Use font-display: swap or optional — users should always see readable text
Loading 4+ static font files for different weights
A single variable font file is smaller than multiple static files combined
Use a variable font — one file covers all weights and styles
Using line-height: 24px instead of unitless line-height: 1.5
line-height: 24px stays fixed even when font-size changes, causing overlap or excess spacing
Always use unitless line-height so it scales with the element's font-size
Ignoring layout shift from font loading
Font swap causes text to reflow when metrics differ between fallback and web font
Use size-adjust and metric overrides on fallback fonts, or use next/font for automatic handling
Quiz
With font-display: optional, what happens if the font doesn't load in time?
Quiz
Why should line-height use unitless values like 1.6 instead of 24px?
Key Rules
  1. 1Use font-display: swap for body text — text must always be readable
  2. 2Use font-display: optional for performance-critical pages — zero layout shift
  3. 3Use size-adjust and metric overrides on fallback fonts to eliminate FOUT layout shift
  4. 4Variable fonts replace multiple static font files with a single smaller download
  5. 5Always use unitless line-height values and max-width: 65ch for optimal reading