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.