@font-face: Web Fonts Done Right
@font-face teaches the browser about a font file it can download and use. Getting it working takes four lines; getting it fast — no invisible text, no jarring layout shifts, no 300 KB of glyphs the page never renders — is where the craft lives. This page covers the full descriptor set, font-display strategies, subsetting with unicode-range, variable fonts, and preloading.
Full Syntax
@font-face {
/* The name you will reference in font-family */
font-family: "Inter";
/* Where to get it — order sources by preference */
src:
local("Inter"), /* installed copy, zero download */
url("/fonts/inter.woff2") format("woff2");
/* Which styles this file covers */
font-weight: 400;
font-style: normal;
/* Loading behavior */
font-display: swap;
/* Which characters this file provides */
unicode-range: U+0000-00FF;
}
/* Then use it like any font */
body {
font-family: "Inter", system-ui, sans-serif;
}@font-face block per weight/style combination (regular, bold, italic…). If a weight is used without a matching block, the browser fakes it (synthetic bold / oblique), which usually looks worse.Which Format? woff2. Just woff2.
In the past, src lists contained eot, ttf, woff, and svg fallbacks. Today WOFF2 is supported by every browser in use and compresses ~30% better than WOFF. Unless you must support truly ancient browsers, ship woff2 only.
Format | Compression | Support | Verdict |
|---|---|---|---|
woff2 | Brotli — best | All modern browsers | Use this |
woff | zlib — good | Universal, incl. very old | Fallback only if needed |
ttf / otf | None | Universal | Never ship raw |
eot, svg fonts | — | IE / legacy iOS only | Dead — do not ship |
font-display: FOUT, FOIT, and Your Choice
While a font downloads, the browser must decide what to show. Two classic failure modes have names: FOIT (Flash of Invisible Text — text hidden until the font arrives) and FOUT (Flash of Unstyled Text — fallback shown first, then swapped). font-display lets you pick the trade-off explicitly.
Value | Block period | Swap period | Behavior |
|---|---|---|---|
auto | Browser default | Browser default | Usually like block — avoid |
block | ~3s invisible | Infinite | FOIT; text hidden up to 3s |
swap | ~0ms | Infinite | FOUT; fallback shown instantly, swaps whenever font lands |
fallback | ~100ms | ~3s | Brief invisibility; if font is late, fallback stays |
optional | ~100ms | None | Font used only if (nearly) instant, else fallback for this visit |
/* Content sites: text must be readable immediately */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
}
/* Performance purist: no layout shift, font is a bonus */
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: optional;
}swap for body text on content sites,optional when you would rather skip the custom font than shift layout, and block only for icon fonts — where fallback glyphs would render as nonsense boxes.To reduce the FOUT jolt with swap, tune the fallback with the size adjustment descriptors so the swap barely moves anything:
/* A metric-compatible fallback that minimizes layout shift */
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter-fallback", sans-serif;
}unicode-range: Ship Only the Glyphs You Need
unicode-range declares which characters a font file covers. The browser downloads the file only if the page actually uses a character in that range — this is how Google Fonts splits fonts into latin, latin-ext, cyrillic, and greek subsets.
/* Latin subset — downloaded for most English pages */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2013-2014,
U+2018-201A, U+201C-201E, U+2026;
}
/* Cyrillic subset — only fetched if the page contains cyrillic text */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-cyrillic.woff2") format("woff2");
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1;
}A full multilingual font can exceed 300 KB; a latin subset is often 15–30 KB. Subsetting is the single biggest font optimization available. Tools: glyphhanger, pyftsubset (fonttools), or simply using the subset files a font provider ships.
Variable Fonts
A variable font packs a continuous range of weights (and often widths or slants) into one file. One @font-face rule replaces six, and you unlock every in-between value.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-weight: 100 900; /* a RANGE, not a single value */
font-style: normal;
font-display: swap;
}
h1 { font-weight: 780; } /* any value in the range */
.subtle { font-weight: 350; }
/* Fine-grained axis control */
.display {
font-variation-settings: "wght" 650, "slnt" -6;
}Preloading Fonts
Fonts are late-discovered resources: the browser only requests one after it has downloaded the CSS and found an element that needs it. preload short-circuits that chain for your critical font.
<head>
<!-- crossorigin is REQUIRED even for same-origin fonts -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link rel="stylesheet" href="/styles/main.css" />
</head>crossoriginattribute causes a silent double download.A Production-Ready Setup
/* fonts.css — self-hosted, subset, variable, swap */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var-latin.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+2013-2014, U+2018-201A, U+201C-201E;
}
:root {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
body { font-family: var(--font-sans); }Key Takeaways
Ship woff2 only; one @font-face per weight/style, or one variable-font rule with a weight range.
Set font-display explicitly: swap for content, optional for zero layout shift, block only for icon fonts.
Subset with unicode-range — the browser downloads a file only when the page uses its characters.
Preload at most one or two critical fonts, always with the crossorigin attribute.
Use size-adjust and metric overrides on a local fallback to tame FOUT layout shift.
Self-host your fonts — cross-site font caching no longer exists.