git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/claude-code/design-web-fonts" ~/.claude/skills/intense-visions-harness-engineering-design-web-fonts && rm -rf "$T"
agents/skills/claude-code/design-web-fonts/SKILL.mdWeb Fonts
Font loading strategy — performance vs. FOUT/FOIT, variable fonts, subsetting, system font stacks, and font-display options
When to Use
- Adding custom fonts to a web project for brand differentiation
- Optimizing font loading to reduce Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS)
- Deciding between self-hosting fonts and using a CDN (Google Fonts, Adobe Fonts)
- Evaluating whether to adopt variable fonts for an existing design system
- Building a system font stack as a zero-cost baseline or permanent solution
- Debugging invisible text (FOIT) or text reflow (FOUT) during page load
Instructions
-
Choose a font-display strategy. The
CSS descriptor controls what happens while a web font is loading. This is the single most impactful decision for font-related performance:font-displayValue Behavior Block Period Swap Period Best For swapShows fallback immediately, swaps when loaded ~100ms Infinite Body text — user sees content instantly optionalShows fallback; may never swap if load is slow ~100ms None Performance-critical sites — no layout shift fallbackBrief invisible, then fallback, limited swap window ~100ms ~3s Balance between swap and optional blockInvisible text for up to 3 seconds ~3s Infinite Icon fonts only (never for text) autoBrowser decides (usually
)blockBrowser-dependent Browser-dependent Never use — behavior is unpredictable Decision procedure:
- For body text: use
(guarantees content is always visible)swap - For headings on performance-critical pages: use
(prevents layout shift; font loads from cache on subsequent visits)optional - For icon fonts: use
(invisible squares are better than showing raw ligature text)block - Never use
or omitauto
— the browser default is effectivelyfont-display
, creating 3 seconds of invisible textblock
- For body text: use
-
Understand FOUT and FOIT. These are the two failure modes of web font loading:
- FOIT (Flash of Invisible Text): text is invisible until the web font loads. Caused by
or browser default. Users see a blank page or missing content for up to 3 seconds. This is the worse failure mode — it hides content.font-display: block - FOUT (Flash of Unstyled Text): text appears in the fallback font, then swaps to the web font when loaded. Caused by
. Users see a brief reflow. This is the better failure mode — content is always visible.font-display: swap - Modern best practice: accept FOUT (use
) and minimize its visual impact by choosing a fallback font with similar metrics to your web font.swap
- FOIT (Flash of Invisible Text): text is invisible until the web font loads. Caused by
-
Use WOFF2 exclusively for modern browsers. Font file formats in order of preference:
- WOFF2: compressed (Brotli), smallest file size, supported by all modern browsers (97%+ global support). Use this.
- WOFF: compressed (zlib), ~20% larger than WOFF2. Only needed for IE11 (effectively dead).
- TTF/OTF: uncompressed, 2-3x larger than WOFF2. Never serve these to browsers.
- EOT: Internet Explorer only. Dead format.
@font-face { font-family: 'Inter'; src: url('/fonts/inter-var.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } -
Subset fonts aggressively. Most projects use only Latin characters. Loading a full Unicode font wastes bandwidth:
- Inter full: ~300KB (all Unicode blocks)
- Inter Latin-only subset: ~20KB (Latin, Latin Extended)
- Inter Latin Basic: ~12KB (ASCII + common accented characters)
- Use
inunicode-range
to load character sets on demand:@font-face
/* Latin characters only */ @font-face { font-family: 'Inter'; src: url('/fonts/inter-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; font-display: swap; }Tools:
(npm) analyzes which characters your site actually uses.glyphhanger
(Python fonttools) creates subsets from any font file.pyftsubset -
Evaluate variable fonts for multi-weight systems. A variable font contains all weights (and potentially widths, optical sizes) in a single file. The tradeoff:
- Single static weight (e.g., Inter Regular 400): ~20KB WOFF2
- Two static weights (400 + 700): ~40KB WOFF2
- Variable font (100-900 weight axis): ~95KB WOFF2
- Breakeven point: if you use 3+ weights from the same family, the variable font is smaller than individual static files (~60KB for 3 static weights vs ~95KB for one variable file)
- Bonus: variable fonts enable intermediate weights (350, 550) for fine-tuned hierarchy without additional file requests
- Decision rule: Using 1-2 weights? Static files. Using 3+ weights or need intermediate values? Variable font.
-
Preload critical fonts. Add
for fonts that appear above the fold:<link rel="preload"><link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />- Preload only 1-2 font files (the primary text font and possibly one heading font)
- The
attribute is required even for same-origin fonts (browser specification requirement)crossorigin - Excessive preloading (3+ fonts) competes with other critical resources and can slow LCP
-
Build a system font stack as baseline or permanent solution. System fonts load in 0ms — they are already on the user's device:
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;Platform resolution:
- macOS/iOS: SF Pro (via
)-apple-system - Windows: Segoe UI
- Android: Roboto
- Linux: Ubuntu or Cantarell (depending on distribution)
System font stacks are not a compromise — GitHub uses system fonts for their entire application UI. The only drawback is loss of brand-specific typography.
- macOS/iOS: SF Pro (via
Details
Self-Hosting vs. CDN
Self-hosting (recommended for most projects):
- Full control over caching headers, subsetting, and file optimization
- No third-party DNS lookup or connection overhead
- No privacy concerns (Google Fonts CDN was ruled a GDPR violation in some EU jurisdictions)
- Can be served from the same domain, eliminating cross-origin overhead
Google Fonts CDN:
- Easy setup (
)<link href="https://fonts.googleapis.com/css2?..."> - Automatic format negotiation and subsetting
- Previously had a cache-sharing advantage (browser cached Google Fonts across sites) — this was eliminated when browsers partitioned third-party caches (Chrome 86+, 2020)
- Current recommendation: download from Google Fonts and self-host. The CDN's cache advantage no longer exists, and self-hosting is faster, more private, and more controllable.
Next.js Font Optimization
Next.js provides automatic font optimization via
next/font:
import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter', });
This automatically:
- Self-hosts the font (no Google Fonts CDN request)
- Subsets to the specified character ranges
- Generates CSS
with@font-facefont-display: swap - Creates a CSS custom property for use in Tailwind or CSS
- Adjusts fallback font metrics to minimize CLS during swap
- Inlines the CSS to avoid render-blocking requests
Font File Size Budget
A practical budget for font loading:
| Category | Budget | Example |
|---|---|---|
| Total font payload | < 100KB | 1 variable font (80KB) + 1 monospace (20KB) |
| Per-font file | < 50KB | Single static weight: ~20KB; Variable: ~80-100KB |
| Critical (above fold) | < 50KB | Preloaded primary font only |
| Fonts per page | 1-3 files | Primary text + optional heading + optional mono |
If your font payload exceeds 200KB, you are loading too many families, weights, or character sets. Audit with Chrome DevTools Network panel filtered to "Font."
Matching Fallback Font Metrics
To minimize FOUT layout shift, configure the fallback font to match the web font's metrics:
@font-face { font-family: 'Inter Fallback'; src: local('Arial'); ascent-override: 90.49%; descent-override: 22.56%; line-gap-override: 0%; size-adjust: 107.64%; } body { font-family: 'Inter', 'Inter Fallback', sans-serif; }
The
size-adjust, ascent-override, descent-override, and line-gap-override descriptors align the fallback font's metrics to the web font, reducing visual shift when the swap occurs. Tools like fontaine (npm) and next/font calculate these values automatically.
Anti-Patterns
-
Loading 6+ font files on initial page load. Each font file is a network request that blocks text rendering. Loading Regular, Italic, Bold, Bold Italic, Light, and Medium variants separately produces 6 requests and 120-180KB of font data. Fix: use a single variable font file, or limit to 2 static weights maximum.
-
No fallback font specified. A
declaration with no fallback produces invisible text if the font fails to load, or defaults to the browser's serif (Times New Roman on most systems) which likely has completely different proportions. Always provide a complete fallback stack.font-family: 'CustomBrandFont' -
Using font-display: block for body text. This hides all body text for up to 3 seconds while the font loads. On slow connections (3G mobile), users see a blank content area. The font may never load if the connection drops. Fix: use
for all text content.font-display: swap -
Loading full character sets for Latin-only content. A full Unicode build of Inter includes Greek, Cyrillic, Vietnamese, and other scripts. If your content is English-only, 90% of the font file is unused data. Fix: subset to Latin Basic (U+0000-00FF) plus any accent characters your content uses.
-
Google Fonts CDN without privacy audit. Google Fonts transmits the user's IP address to Google servers. In the EU, this has been ruled a GDPR violation (Munich Regional Court, January 2022). Even outside the EU, the unnecessary data transmission is a liability. Fix: download fonts and self-host them.
Real-World Examples
GitHub's System Font Strategy GitHub uses system fonts exclusively for the application UI:
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Result: zero font-loading overhead, zero FOUT/FOIT, instant text rendering. Font payload: 0KB. GitHub reserves custom fonts (Mona Sans, Hubot Sans) only for marketing pages where brand expression justifies the performance cost.
Vercel's Geist Font Implementation Vercel self-hosts Geist as a variable font via
next/font:
- Single WOFF2 variable file (~80KB) covering weights 100-900
- Subset to Latin characters
- Preloaded with
<link rel="preload">
for immediate text visibilityfont-display: swap- Geist Mono loaded separately (~35KB) only on pages with code blocks
- Total font budget: 80-115KB depending on page type
Stripe's Font Loading Stripe loads sohne-var (variable) self-hosted:
- Single variable font file covering weights 300-400 (the only weights they use)
- Custom subset: Latin + select currency symbols
- Preloaded on all pages
- Source Code Pro loaded only on API documentation pages
with carefully tuned fallback metrics to minimize CLSfont-display: swap
Source
- web.dev — "Best practices for fonts" (Google Chrome team)
- MDN Web Docs —
descriptor,font-display
at-rule@font-face - Zach Leatherman — "A Comprehensive Guide to Font Loading Strategies"
- Chrome DevTools — Network panel font analysis
- WCAG 2.1 SC 1.4.12 — Text Spacing (font rendering implications)
Process
- Evaluate — Audit the current font loading strategy. Measure total font payload, count font file requests, check
values, and identify whether subsetting is applied. Run Lighthouse to check CLS from font swap.font-display - Apply — Implement the optimal loading strategy: WOFF2 format, aggressive subsetting,
for text, preload critical fonts, self-host rather than CDN. Consider variable fonts if using 3+ weights.font-display: swap - Verify — Confirm total font payload is under 100KB, no FOIT occurs on throttled connections (Chrome DevTools slow 3G), CLS from font swap is under 0.01, and fallback fonts have matched metrics.
Harness Integration
This is a knowledge skill. When activated, it provides font loading performance expertise to guide
@font-face declarations, preload hints, and font file optimization. Use these principles when configuring next/font, adding custom fonts to any web framework, or auditing Core Web Vitals related to font loading. Cross-reference with design-typography-fundamentals for font selection and design-responsive-type for viewport-adaptive type.
Success Criteria
- Total font payload is under 100KB for a typical page load
- All text fonts use
orfont-display: swapfont-display: optional - Fonts are served as WOFF2 only (no TTF, OTF, or WOFF fallbacks for modern browsers)
- Fonts are subsetted to only the character ranges the content requires
- No more than 3 font files are loaded on initial page render
- Fallback fonts have metric overrides to minimize CLS during swap
- Fonts are self-hosted (no third-party CDN for production)