2.2 — Fonts¶
Module 2 · Lesson 2 · 🟢 Foundational · ~25 min
What you'll learn¶
- Why web fonts cost you both LCP and CLS, and how to pay neither
next/fontcorrectly, including variable fonts and subsettingsize-adjustfallback metrics — the highest‑value 10 lines in this module- The brand‑vs‑performance conversation, and how to win it with data
The two costs¶
Cost 1 — LCP delay. A text LCP element can't paint in its final form until the font loads.
With font-display: block (the default for @font-face without the descriptor) the browser hides
the text for up to 3 s. With swap, it paints in a fallback and swaps — no LCP delay, but…
Cost 2 — CLS. …the fallback font has different metrics, so when the real font swaps in, every line reflows. On a PDP with a long product title and description, that's a shift of the entire page below it.
The naive fixes trade one for the other:
| Approach | LCP | CLS |
|---|---|---|
font-display: block |
🔴 Text invisible up to 3 s | 🟢 No shift |
font-display: swap |
🟢 Paints immediately | 🔴 Shift on swap |
swap + size-adjust fallback |
🟢 | 🟢 |
| System fonts only | 🟢 | 🟢 |
The third row is the answer, and next/font gives it to you almost for free.
next/font — what it actually does¶
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
// Metric-compatible fallbacks. next/font computes size-adjust,
// ascent-override, descent-override and line-gap-override for you.
fallback: ['system-ui', 'arial'],
adjustFontFallback: true, // default true — do not turn this off
});
const playfair = Playfair_Display({
subsets: ['latin'],
display: 'swap',
variable: '--font-playfair',
weight: ['400', '700'], // only what you use
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
Four things happen here that you'd otherwise do by hand:
- Self‑hosting. The font file is downloaded at build time and served from your own origin.
No connection to
fonts.gstatic.com, no extra DNS+TLS on the critical path (worth 100–300 ms), and no third‑party dependency. - Automatic preload. A
<link rel="preload">for the font file used by the initial render. - Automatic fallback metric adjustment (
adjustFontFallback) — the CLS fix, below. - No layout‑shift‑inducing FOUT for the common case.
For local/licensed brand fonts:
import localFont from 'next/font/local';
const brandSans = localFont({
src: [
{ path: './fonts/AuroraSans-Variable.woff2', style: 'normal' },
{ path: './fonts/AuroraSans-Italic-Variable.woff2', style: 'italic' },
],
display: 'swap',
variable: '--font-brand',
// Metrics from the actual font file — see "How to get these numbers" below
declarations: [{ prop: 'size-adjust', value: '105.2%' }],
});
The size-adjust fix, explained¶
When the fallback and the web font have different metrics, the swap reflows text. size-adjust
and the override descriptors scale the fallback so it occupies the same space as the real
font. The swap then changes glyph shapes without moving a single line.
/* What next/font generates for you (simplified) */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.12%;
ascent-override: 90.20%;
descent-override: 22.48%;
line-gap-override: 0.00%;
}
@font-face {
font-family: 'Inter';
src: url('/_next/static/media/inter-latin.woff2') format('woff2');
font-display: swap;
}
body { font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif; }
How to get these numbers for a custom brand font:
# Compute metric overrides for a local font against a chosen fallback
npx fontpie ./fonts/AuroraSans-Variable.woff2 --fallback arial
# Or inspect the raw metrics yourself
npx fontkit-cli ./fonts/AuroraSans-Variable.woff2
# unitsPerEm, ascent, descent, lineGap, xHeight, and the average character width
# ratio vs the fallback give you size-adjust
Aurora's brand font swap was causing 0.09 of CLS on the PDP by itself. Adding the fallback metrics took it to 0.004. That's a 15‑minute change.
Verify it worked: record a Performance trace with network throttled to Slow 3G (so the swap is visible), and check the Experience track for layout shifts at the moment the font loads. Or in the Rendering panel, enable "Layout Shift Regions" and reload.
Reducing font bytes¶
| Technique | Typical saving | Notes |
|---|---|---|
| WOFF2 only | −30% vs WOFF | Universal support; never ship TTF/OTF/EOT to the web |
| Subset to the scripts you need | −40% to −90% | subsets: ['latin'] — huge for CJK/Cyrillic families |
| Variable font instead of 4–6 statics | −40% to −60% | One file covers a weight range |
| Drop unused weights | −25% per weight | Most sites load 5 weights and use 2 |
| Drop italic if unused | −20% | Check your design system for actual usage |
unicode-range splitting |
Lazy per‑script | Google Fonts does this automatically |
Variable font math for Aurora:
Before: Regular 400 (28KB) + Medium 500 (29KB) + SemiBold 600 (29KB)
+ Bold 700 (28KB) + Italic 400 (27KB) = 141 KB
After: AuroraSans-Variable.woff2 (weight 300-800) 62KB
+ AuroraSans-Italic-Variable.woff2 58KB = 120 KB
…but italic is only used in one component → lazy-load it:
critical = 62 KB
−56% on the critical path, from a build config change.
Custom subsetting for a fixed character set (nav, logo, or a display font used only in headings) is worth doing manually:
# glyphhanger walks your rendered pages and reports exactly which glyphs you use
npx glyphhanger https://www.auroramarket.com --spider --spider-limit=50 --formats=woff2 \
--subset=./fonts/AuroraDisplay.woff2
# Or subset to a known set with pyftsubset (fonttools)
pyftsubset AuroraDisplay.woff2 \
--unicodes="U+0020-007E,U+00A0-00FF,U+2018-201D,U+2022,U+2013-2014" \
--layout-features="kern,liga,calt" \
--flavor=woff2 \
--output-file=AuroraDisplay-subset.woff2
# 84KB → 19KB for a Latin-1 + smart-punctuation subset
Careful with aggressive subsetting on a commerce site: product names contain characters you didn't plan for — accented names, ™, ®, ½, ×, °, currency symbols, and user‑generated review text. Include Latin‑1 Supplement, General Punctuation, and your currency symbols at minimum, and have a sane fallback for anything outside the subset.
Preloading and ordering¶
next/font preloads automatically for fonts used in the initial render. If you're hand‑rolling:
<!-- Correct: crossorigin is REQUIRED for fonts, even same-origin.
Without it the browser fetches the font twice. -->
<link rel="preload" href="/fonts/aurora-sans.woff2" as="font" type="font/woff2" crossorigin />
Rules:
- Preload at most 1–2 fonts. Each one competes with your LCP image for bandwidth. If your LCP element is an image, preloading three fonts actively hurts LCP.
- Don't preload fonts used only below the fold (a display font in the footer, an icon font in a modal).
- Never
@importfonts in CSS. It creates a serial dependency: CSS must download and parse before the font request even starts.
/* ❌ Serial: HTML → CSS → @import CSS → font. Three round trips. */
@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');
Icon fonts: don't¶
Icon fonts are a legacy pattern with three real costs: a render‑blocking font file, invisible icons during load (or boxes), and accessibility problems. They also can't be tree‑shaken — you ship 200 glyphs to use 12.
// ❌ 48KB icon font for 12 icons, blocks render, breaks with font-display: swap
<i className="icon icon-cart" />
// ✅ Inline SVG: ~300 bytes each, no extra request, styleable, accessible,
// and server-rendered (no client JS)
export function CartIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none"
stroke="currentColor" strokeWidth="1.5" aria-hidden="true" {...props}>
<path d="M3 3h2l2.4 12.3a2 2 0 0 0 2 1.7h7.7a2 2 0 0 0 2-1.6L21 8H6" />
<circle cx="9" cy="20" r="1.5" /><circle cx="18" cy="20" r="1.5" />
</svg>
);
}
For a large icon set, use an SVG sprite or a build‑time icon component generator — but never a
font, and never a barrel import of an icon library (see
4.3 Dependency diet, where lucide-react's barrel costs
Aurora 90 KB).
The brand conversation¶
Design will push back on font changes. Bring numbers, not opinions:
| Argument | Data to bring |
|---|---|
| "We need all 6 weights" | Usage audit: grep the codebase for font-weight/font- classes. Usually 2–3 are used |
| "The brand font must load first" | It does — swap + metric fallback means users see correct layout immediately and correct glyphs ~200 ms later |
| "The fallback looks wrong" | Show a screenshot at the moment of swap with metrics matched. Most people can't tell |
| "We can't subset, product names vary" | Correct — subset to Latin‑1 + punctuation + currency, not to ASCII |
"Just use font-display: block" |
Show them the 3 s blank text on Slow 3G on a real phone |
The winning frame: you are not removing the brand font, you are making it arrive without costing the user a blank screen or a page jump. Nobody argues with that.
Common mistakes¶
| Mistake | Cost |
|---|---|
Google Fonts via <link> to fonts.gstatic.com |
Extra DNS+TLS on the critical path (100–300 ms) |
@import in CSS |
Serial dependency chain |
font-display: block (or omitting the descriptor) |
Up to 3 s of invisible text |
swap without metric fallbacks |
0.05–0.15 CLS |
| Preloading 4 fonts | Bandwidth contention with the LCP image |
Missing crossorigin on font preload |
Font downloaded twice |
| Shipping 6 static weights | 100+ KB that a variable font does in 60 |
| Icon fonts | Blocking request, invisible icons, a11y issues |
| Loading the italic variable font for one component | Lazy‑load it via a scoped @font-face instead |
Lab 2.2 — Font audit¶
- Inventory: DevTools → Network → Font. How many files, total bytes, from which origins?
- Usage: grep for actual weight/style usage. Compare to what you load. Delete the difference.
- CLS check: throttle to Slow 3G, enable Rendering → Layout Shift Regions, reload. If the page jumps when text sharpens, you're missing metric fallbacks.
- Migrate to
next/fontwithdisplay: 'swap'andadjustFontFallback: true. - Consolidate to variable fonts; drop unused weights and italics.
- Re‑measure LCP and CLS. Expect CLS −0.03 to −0.10 and LCP −100 to −400 ms if you were using a third‑party font host.
Checklist¶
- Fonts self‑hosted via
next/font(no third‑party font origin on the critical path) -
display: swapeverywhere - Metric‑compatible fallbacks in place (
adjustFontFallbackor explicitsize-adjust) - WOFF2 only, subset to the scripts you need + Latin‑1 + punctuation + currency
- Variable fonts where you use 3+ weights
- At most 1–2 preloaded fonts, only ones used above the fold
-
crossoriginon any manual font preload - No icon fonts
- Font CLS verified as ~0 with a throttled trace
Next: 2.3 Third‑party scripts