Web Fonts
Typography defines a site's personality, but fonts are also one of the most common sources of render-blocking behavior and layout shift. This page covers every aspect of web font delivery -- from @font-face syntax to variable fonts, subsetting, preloading, and fallback strategies.
Hub: Graphics & Web Design Related: CSS Techniques | Web Performance
@font-face Syntax
The @font-face rule tells the browser where to find a font file and how to identify it:
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.woff2') format('woff2'),
url('/fonts/Inter-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
Key points:
- Always list woff2 first -- it has the best compression and universal modern browser support.
- Use unicode-range to split fonts into subsets so the browser only downloads the character sets actually used on the page.
- Each weight and style combination gets its own @font-face block.
font-display Strategies
The font-display descriptor controls what happens while the font is loading:
| Value | Behavior |
|---|---|
swap |
Shows fallback immediately, swaps when font loads. Best for body text. |
optional |
Brief block period, then fallback permanently if font has not arrived. Best for slow connections. |
fallback |
Short block (~100ms), short swap (~3s), then fallback permanently. A middle ground. |
block |
Long invisible period (up to 3s). Avoid for body text. |
auto |
Browser decides. Unpredictable. |
For most sites, swap is the right default for headings and body text. Use optional when you want to prioritize performance over visual consistency -- the user sees a system font on slow connections, and the custom font on fast ones.
/* Body text: always show something */
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-var.woff2') format('woff2-variations');
font-display: swap;
}
/* Decorative display font: optional for performance */
@font-face {
font-family: 'Playfair';
src: url('/fonts/Playfair-Display.woff2') format('woff2');
font-display: optional;
}
Subsetting with pyftsubset
Most font files include thousands of glyphs for languages you may never use. Subsetting strips unused characters to reduce file size dramatically -- often by 70-90 percent.
# Install fonttools
pip install fonttools brotli
# Subset to Latin characters only
pyftsubset Inter-Regular.ttf \
--output-file=Inter-Regular-latin.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt' \
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD'
# Subset to only the characters used on your site
pyftsubset Inter-Regular.ttf \
--output-file=Inter-custom.woff2 \
--flavor=woff2 \
--text-file=used-characters.txt
The --layout-features flag preserves OpenType features like kerning and ligatures. Without it, your subsetted font may look subtly wrong.
Automating Subsetting in a Build
import subprocess
import pathlib
FONTS_DIR = pathlib.Path("src/fonts")
OUTPUT_DIR = pathlib.Path("dist/fonts")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
LATIN_RANGE = (
"U+0000-00FF,U+0131,U+0152-0153,"
"U+02BB-02BC,U+02C6,U+02DA,U+02DC,"
"U+2000-206F,U+2074,U+20AC,U+2122,"
"U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
)
for font_file in FONTS_DIR.glob("*.ttf"):
output = OUTPUT_DIR / f"{font_file.stem}-latin.woff2"
subprocess.run([
"pyftsubset", str(font_file),
f"--output-file={output}",
"--flavor=woff2",
"--layout-features=kern,liga,calt",
f"--unicodes={LATIN_RANGE}",
], check=True)
original_kb = font_file.stat().st_size / 1024
subset_kb = output.stat().st_size / 1024
print(f"{font_file.name}: {original_kb:.0f}KB -> {subset_kb:.0f}KB")
Variable Fonts
Variable fonts pack multiple weights, widths, and styles into a single file. Instead of downloading separate files for Regular, Medium, Semibold, and Bold, one variable font file covers the entire range.
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-var.woff2') format('woff2-variations');
font-weight: 100 900; /* Full weight range */
font-style: normal;
font-display: swap;
}
/* Use any weight value in the range */
h1 { font-weight: 800; }
h2 { font-weight: 650; }
p { font-weight: 400; }
/* Access named axes with font-variation-settings */
.fine-control {
font-variation-settings:
'wght' 575,
'wdth' 95,
'slnt' -5;
}
Variable fonts are a performance win when you use three or more weights. A single 80KB variable font file replaces three 40KB static files.
Self-Hosting vs. Google Fonts
Google Fonts is convenient but has performance costs: a DNS lookup, a connection to fonts.googleapis.com, a second connection to fonts.gstatic.com, and a render-blocking CSS file.
Self-hosting eliminates cross-origin connections and gives you full control over caching, subsetting, and font-display.
# Download Google Fonts for self-hosting
# Use google-webfonts-helper or fontsource
npm install @fontsource-variable/inter
/* Import from node_modules in your CSS build */
@import '@fontsource-variable/inter';
Self-hosted fonts served with Cache-Control: public, max-age=31536000, immutable and a hashed filename are cached indefinitely and require zero third-party connections.
FOUT and FOIT Strategies
- FOUT (Flash of Unstyled Text): The fallback font is visible, then swaps to the custom font. Caused by
font-display: swap. Jarring but functional. - FOIT (Flash of Invisible Text): Text is invisible until the font loads. Caused by
font-display: block. Worse for users on slow connections.
To minimize FOUT, match the fallback font's metrics to the custom font:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
The size-adjust and ascent-override properties tune the fallback to match the custom font's metrics, dramatically reducing the visual jump when the swap occurs.
Preloading Fonts
Use <link rel="preload"> to start downloading critical fonts before the CSS is parsed:
<link rel="preload"
href="/fonts/Inter-var.woff2"
as="font"
type="font/woff2"
crossorigin />
The crossorigin attribute is required even for same-origin fonts -- this is a specification quirk of font loading. Without it, the browser downloads the font twice.
Only preload fonts that appear above the fold. Preloading too many fonts wastes bandwidth and delays other critical resources.
System Font Stack Fallback
When custom fonts are not worth the performance cost, the system font stack provides a native look with zero download time:
body {
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji';
}
code, pre {
font-family:
ui-monospace,
'Cascadia Code',
'Source Code Pro',
Menlo,
Consolas,
'DejaVu Sans Mono',
monospace;
}
This stack renders instantly with no layout shift, no FOUT, and no network requests. It is the ideal baseline and a worthy production choice for many applications.