Web Font Loading skill for Claude Code
Fonts that arrive early and move nothing when they swap. Claude audits which font files load and when, cuts unused weights, self-hosts and subsets to WOFF2 with unicode-range, and preloads only the file the first screen needs. A bundled stdlib script reads the font's own metric tables and prints a fallback @font-face with size-adjust and ascent/descent overrides, so the swap from Arial to the web font reflows nothing.
- name
web-font-loading - size230 lines · ≈2,800 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Makes web fonts fast and shift-free — audits font requests, self-hosts instead of third-party CSS, subsets with pyftsubset or glyphhanger and unicode-range, ships WOFF2 only, picks font-display per role, preloads only the critical file with crossorigin, trims variable font axes, and computes size-adjust, ascent-override and descent-override fallbacks with a stdlib script. Use when fonts cause CLS, FOIT or FOUT, Lighthouse flags font requests, font files are large, or when setting up next/font, fontaine, capsize, or Google Fonts. Try asking
- The headline jumps when our custom font loads — make the swap invisible
- Move us off the Google Fonts link and self-host Inter properly
- We ship 400 KB of fonts. Cut that down without changing how the site looks
Use it when
- Layout shift or invisible text happens while fonts load
- Font files are large or there are many weights and styles
- Migrating from Google Fonts or Typekit links to self-hosting
- Configuring next/font, @nuxt/fonts, fontaine or capsize fallbacks
Skip it when
- You need a type scale, pairing or line-length rules — use typography-scale
- Fonts are fine and LCP or INP is the problem — start with core-web-vitals
How to check the result
A Slow 4G DevTools trace shows each font fetched once at high priority and no Layout Shift entry when the web font replaces the fallback.
The common mistake it prevents
Preloading a font without the crossorigin attribute, so the browser discards the preload and downloads the same file a second time.
Done well means: text paints on the first frame in a fallback that is metric-matched to the web font, the web font file the first screen needs is requested at high priority in the first round trip, and when it swaps in nothing moves (font-caused CLS ≈ 0). Initial-view font bytes stay under ~100 KB compressed, usually 1–3 WOFF2 files.
Type scale, pairing, and line length belong to typography-scale; this skill covers
bytes, requests, and swap behavior.
Workflow
- [ ] 1. Audit: which font files load, from where, how big, when
- [ ] 2. Cut: families, weights, styles, axes actually used
- [ ] 3. Self-host and subset to WOFF2
- [ ] 4. Write @font-face with unicode-range and font-display
- [ ] 5. Preload only the first-screen file(s)
- [ ] 6. Generate metric-matched fallback faces
- [ ] 7. Cache headers and CORS
- [ ] 8. Verify on throttled network: no shift, one download per file
1. Audit
# Every @font-face and font import in the source
rg -n "@font-face|fonts.googleapis|fonts.gstatic|use.typekit|next/font|@fontsource" --glob '!node_modules'
# Sizes of self-hosted files
find public static assets -name '*.woff*' -o -name '*.ttf' -o -name '*.otf' 2>/dev/null | xargs ls -lh
# What a third-party CSS actually serves to a modern browser
curl -s -A "Mozilla/5.0 Chrome/130" "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" | head -30
In DevTools Network, filter Font, reload with cache disabled, and record for each file:
size, priority, start time relative to FCP, and initiator (CSS vs preload). Check the
Performance panel’s Layout Shift track for shifts that coincide with font arrival.
2. Cut what is not used
- Count distinct
font-weight/font-stylevalues in CSS; every combination is a file. Map rarely used weights (e.g. 500 used once) to a loaded one. - Faux bold/italic is worse than a missing weight being mapped: add
font-synthesis: noneon the root when every needed style is loaded, so a missing file shows up in review instead of rendering a smeared synthetic bold. - Variable vs static: one family in 3+ weights → a variable WOFF2 is usually smaller than the statics combined. One or two weights → statics usually win. Measure both.
3. Self-host and subset
Self-host by default: browsers partition the HTTP cache per site, so a shared Google Fonts cache hit no longer happens, and the third-party CSS adds a DNS + TLS + CSS round trip before the font request can even start. Self-hosting also avoids sending visitor IPs to a third party, which matters for GDPR.
pip install fonttools brotli
# Latin subset (the range Google Fonts uses for "latin"), WOFF2, common features kept
pyftsubset Inter-Variable.ttf \
--unicodes="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+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" \
--layout-features="kern,liga,calt,ccmp,locl,mark,mkmk,tnum" \
--flavor=woff2 --output-file=inter-latin-var.woff2
# Narrow a variable font's axes before subsetting (drops unused weight range)
fonttools varLib.instancer Inter-Variable.ttf wght=400:700 -o Inter-400-700.ttf
# Find characters a site actually uses (crawls pages) — useful for display/logo fonts
npx glyphhanger https://localhost:3000 --spider --spider-limit=50
Subsetting rules:
- Keep
tnumif tables or prices use tabular figures; keepkernandligaalways. - Split by script with
unicode-range(latin, latin-ext, cyrillic…) instead of one file with everything; the browser downloads only ranges present on the page. - For a logo or headline font used for a fixed string, subset to exactly those glyphs.
- Do not subset user-generated content fonts too tightly — names with accents fall back to a different face mid-word. Include latin-ext as a second range file.
- Ship WOFF2 only. Every browser in support since ~2016 reads it; TTF/WOFF/EOT
fallbacks in
srcare dead bytes in the CSS.
4. @font-face
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-var.woff2") format("woff2");
font-weight: 400 700; /* range for a variable font */
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122, U+2212;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-ext-var.woff2") format("woff2");
font-weight: 400 700;
font-display: swap;
unicode-range: U+0100-024F, U+1E00-1EFF;
}
Choose font-display per role:
| Role | Value | Behavior |
|---|---|---|
| Body and UI text with a metric-matched fallback | swap | Fallback paints immediately; swap is invisible if metrics match |
| Text where a late swap is worse than never swapping (long reading pages, slow markets) | optional | ~100 ms block, then the fallback stays for this page view; font is cached for the next one. Zero font CLS |
| Headlines in a distinctive display face | swap + exact-glyph subset + preload | Small file arrives before or near FCP |
| Icon fonts | Replace with SVG icons | block hides text for up to 3 s; ligature icons read as words to screen readers |
Put @font-face in CSS that is in the initial HTML (inline in <head> or the first
stylesheet). An @import chain or a CSS-in-JS runtime delays discovery by round trips.
5. Preload — only what the first screen renders
<link rel="preload" href="/fonts/inter-latin-var.woff2" as="font" type="font/woff2" crossorigin>
crossoriginis mandatory even for same-origin fonts. Without it the preload is fetched in a different mode, discarded, and the font downloads twice.- The
hrefmust match thesrcURL byte for byte (including query strings and hashes). - Preload one or two files: the body face regular weight and maybe the headline face. Preloading every weight steals bandwidth from the LCP image and CSS.
- Never preload a
unicode-rangefile the page may not need (latin-ext, cyrillic). - Staying on Google Fonts for now:
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>,&display=swapin the CSS URL, and&text=Brand%20Namefor single-string display fonts.
6. Metric-matched fallback
The fallback must take the same horizontal and vertical space as the web font, or the swap reflows text. Generate the overrides from the font files:
python3 scripts/fallback_metrics.py fonts/src/InterVariable.ttf --family Inter
python3 scripts/fallback_metrics.py fonts/Lora.ttf --family Lora --fallback times
python3 scripts/fallback_metrics.py fonts/Brand.otf --fallback /path/to/Arial.ttf
It reads head, hhea, OS/2, hmtx, and cmap (TTF, OTF, WOFF, TTC; not WOFF2 —
pass the source TTF), respects USE_TYPO_METRICS, computes a letter-frequency weighted
average width, and prints a ready @font-face for the fallback (shape shown below;
the percentages are illustrative, use the numbers the script prints for your file):
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107.12%;
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0.00%;
}
body { font-family: "Inter", "Inter Fallback", sans-serif; }
Math, for checking by hand: size-adjust = (webAvgWidth / webUPM) / (fbAvgWidth / fbUPM);
each override = webMetric / webUPM / size-adjust. Pick the fallback per platform shape:
Arial for sans (present on Windows/macOS; Linux maps it to Liberation Sans with the same
metrics), Times New Roman for serif. Add a second fallback face with local("Roboto") for
Android if analytics show heavy Android traffic.
Framework shortcuts that do the same thing:
| Stack | Tool |
|---|---|
| Next.js | next/font/google or next/font/local — self-hosts, subsets, and generates the fallback (adjustFontFallback on by default) |
| Nuxt | @nuxt/fonts — resolves, self-hosts, and generates fallbacks (uses fontaine) |
| Vite / other bundlers | fontaine plugin — injects override faces for declared fallbacks |
| Any (CSS in JS or build script) | @capsizecss/metrics + createFontStack from @capsizecss/core |
| Fontsource packages | @fontsource-variable/<name> for self-hosted files; still add fallback faces |
Browser support: size-adjust works in current Chrome, Edge, Firefox, and Safari. The
ascent-override/descent-override/line-gap-override descriptors have historically
lacked Safari support — check caniuse for the target audience; width matching via
size-adjust alone still removes most horizontal reflow.
7. Cache headers and CORS
- Fingerprint font filenames (
inter-latin-var.3f9a1c.woff2) and serveCache-Control: public, max-age=31536000, immutable. - Fonts on another origin (CDN domain) need
Access-Control-Allow-Originon the font response; fonts are always fetched in CORS mode. - Serve
Content-Type: font/woff2. Do not gzip/brotli WOFF2 again; it is already compressed.
Gotchas
- Preloading a font that CSS never uses triggers a console warning after ~3 s and wastes bandwidth; preloading the wrong subset file is the common cause.
local()first insrccan pick up an older, differently-metricked version installed on the user’s machine. Uselocal()only in the fallback face.- A
font-weight: 400single value on a variable font face clamps it; declare the range (400 700) or bold renders synthetic. - Fallback overrides tuned for one weight are applied to all; check bold headings too, and add per-weight fallback faces if headlines visibly reflow.
font-displayon the Google Fonts<link>is ignored; it must be thedisplay=query parameter or inside@font-face.- Hidden text during load (FOIT) in Safari and Chrome lasts up to 3 s with
blockorauto—autobehaves likeblockin most browsers. - Self-hosted fonts behind a service worker or auth middleware can return HTML for a
font URL; check that font responses are actually
font/woff2.
Verification
- DevTools Network (cache disabled, Slow 4G): each font file downloads once, preloaded files show High priority and start before or alongside the main CSS.
- Performance panel: no Layout Shift entries when the font swaps in. Total CLS from fonts ≈ 0 (compare the web-vitals attribution target before and after).
- Visual check: temporarily force
font-family: "Inter Fallback"and overlay a screenshot on the web-font version; line breaks and block heights should match closely. - Lighthouse: “Ensure text remains visible during webfont load” passes; no “preloaded but not used” warnings in the console.
- Bytes:
ls -lhon the shipped WOFF2 files; initial-view total under the budget.
Output
**Before:** 6 files, 412 KB (Google Fonts CSS → 4 static weights + 2 italics), CLS 0.09 from swap
**After:** 2 files, 74 KB (Inter variable latin 400–700 + latin-ext on demand), self-hosted
**Loading:** preload inter-latin-var.woff2 (crossorigin); font-display: swap
**Fallback:** "Inter Fallback" local(Arial) size-adjust 107.12%, ascent 90.44%, descent 22.52%
**Verified:** Slow 4G trace shows no layout shift at swap; each file fetched once Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/fallback_metrics.py
#!/usr/bin/env python3
"""Compute CSS fallback-font metric overrides for a web font (stdlib only).
Reads the sfnt tables (head, hhea, OS/2, hmtx, cmap) of a TTF/OTF/WOFF(1) file,
computes a frequency-weighted average character width, and prints an
@font-face block for a local fallback (Arial, Times New Roman, ...) with
size-adjust, ascent-override, descent-override and line-gap-override so the
fallback occupies the same space as the web font and the swap causes no shift.
Usage:
python3 fallback_metrics.py WEBFONT [--fallback arial|times|PATH] [--family NAME]
python3 fallback_metrics.py WEBFONT --fallback /System/Library/Fonts/Supplemental/Arial.ttf
WOFF2 needs Brotli (not in the stdlib): pass the original TTF/OTF instead, or
decompress first (e.g. `woff2_decompress font.woff2`).
Exit codes: 0 ok, 1 unreadable/unsupported font, 2 bad arguments.
"""
import argparse
import os
import struct
import sys
import zlib
# English letter frequencies (percent) including space; used to weight widths,
# the same idea capsize/fontaine use for xWidthAvg.
FREQ = {
" ": 18.3, "e": 10.2, "t": 7.5, "a": 6.5, "o": 6.2, "n": 5.7, "i": 5.7,
"s": 5.3, "r": 5.0, "h": 4.9, "l": 3.3, "d": 3.3, "u": 2.3, "c": 2.2,
"m": 2.0, "f": 1.8, "w": 1.7, "g": 1.6, "y": 1.6, "p": 1.5, "b": 1.3,
"v": 0.8, "k": 0.6, "x": 0.1, "j": 0.1, "q": 0.1, "z": 0.1,
}
# Preset fallbacks. Prefer passing a real path with --fallback when available.
SUP = "/System/Library/Fonts/Supplemental/"
PRESETS = {
"arial": {"name": "Arial", "upm": 2048, "generic": "sans-serif", "path": SUP + "Arial.ttf"},
"times": {"name": "Times New Roman", "upm": 2048, "generic": "serif", "path": SUP + "Times New Roman.ttf"},
"georgia": {"name": "Georgia", "upm": 2048, "generic": "serif", "path": SUP + "Georgia.ttf"},
"verdana": {"name": "Verdana", "upm": 2048, "generic": "sans-serif", "path": SUP + "Verdana.ttf"},
}
# Weighted widths measured with this script from the macOS copies; used when the
# file is absent (Linux CI). Arial/Times metrics match their Windows versions.
STATIC_AVG = {"arial": 900.7, "times": 816.1}
class FontError(Exception):
pass
def load_tables(path, index=0):
with open(path, "rb") as fh:
data = fh.read()
tag = data[:4]
if tag == b"wOF2":
raise FontError("WOFF2 needs Brotli; pass the TTF/OTF source or decompress it first")
if tag == b"wOFF":
num = struct.unpack(">H", data[12:14])[0]
tables = {}
for i in range(num):
off = 44 + i * 20
t, o, clen, olen, _ = struct.unpack(">4sIIII", data[off:off + 20])
raw = data[o:o + clen]
tables[t.decode("latin-1")] = zlib.decompress(raw) if clen < olen else raw
return tables
base = 0
if tag == b"ttcf":
count = struct.unpack(">I", data[8:12])[0]
if index >= count:
raise FontError(f"collection has {count} fonts; index {index} out of range")
base = struct.unpack(">I", data[12 + 4 * index:16 + 4 * index])[0]
elif tag not in (b"\x00\x01\x00\x00", b"OTTO", b"true"):
raise FontError(f"not an sfnt/WOFF font (magic {tag!r})")
num = struct.unpack(">H", data[base + 4:base + 6])[0]
tables = {}
for i in range(num):
off = base + 12 + i * 16
t, _, o, ln = struct.unpack(">4sIII", data[off:off + 16])
tables[t.decode("latin-1")] = data[o:o + ln]
return tables
def parse_cmap(cmap):
"""Return dict codepoint->glyph id for format 4 or 12 Unicode subtables."""
n = struct.unpack(">H", cmap[2:4])[0]
best = None
for i in range(n):
pid, eid, off = struct.unpack(">HHI", cmap[4 + i * 8:12 + i * 8])
fmt = struct.unpack(">H", cmap[off:off + 2])[0]
unicode_sub = pid == 0 or (pid == 3 and eid in (1, 10))
if unicode_sub and fmt in (4, 12) and (best is None or fmt == 12):
best = (fmt, off)
if best is None:
raise FontError("no Unicode cmap subtable (format 4 or 12)")
fmt, off = best
out = {}
if fmt == 4:
segx2 = struct.unpack(">H", cmap[off + 6:off + 8])[0]
seg = segx2 // 2
ends = struct.unpack(f">{seg}H", cmap[off + 14:off + 14 + segx2])
p = off + 16 + segx2
starts = struct.unpack(f">{seg}H", cmap[p:p + segx2])
deltas = struct.unpack(f">{seg}h", cmap[p + segx2:p + 2 * segx2])
ro_pos = p + 2 * segx2
ranges = struct.unpack(f">{seg}H", cmap[ro_pos:ro_pos + segx2])
for s in range(seg):
for cp in range(starts[s], min(ends[s], 0x2FF) + 1):
if ranges[s] == 0:
gid = (cp + deltas[s]) & 0xFFFF
else:
gpos = ro_pos + 2 * s + ranges[s] + 2 * (cp - starts[s])
gid = struct.unpack(">H", cmap[gpos:gpos + 2])[0]
if gid:
gid = (gid + deltas[s]) & 0xFFFF
out[cp] = gid
else:
ngroups = struct.unpack(">I", cmap[off + 12:off + 16])[0]
for g in range(ngroups):
s, e, sg = struct.unpack(">III", cmap[off + 16 + g * 12:off + 28 + g * 12])
for cp in range(s, min(e, 0x2FF) + 1):
out[cp] = sg + (cp - s)
return out
def metrics(path, index=0):
t = load_tables(path, index)
for need in ("head", "hhea", "hmtx", "cmap"):
if need not in t:
raise FontError(f"missing '{need}' table")
upm = struct.unpack(">H", t["head"][18:20])[0]
h_asc, h_desc, h_gap = struct.unpack(">hhh", t["hhea"][4:10])
n_hmetrics = struct.unpack(">H", t["hhea"][34:36])[0]
asc, desc, gap, source = h_asc, h_desc, h_gap, "hhea"
x_avg_os2 = None
if "OS/2" in t and len(t["OS/2"]) >= 78:
os2 = t["OS/2"]
x_avg_os2 = struct.unpack(">h", os2[2:4])[0]
fs_sel = struct.unpack(">H", os2[62:64])[0]
typo_asc, typo_desc, typo_gap = struct.unpack(">hhh", os2[68:74])
if fs_sel & (1 << 7): # USE_TYPO_METRICS
asc, desc, gap, source = typo_asc, typo_desc, typo_gap, "OS/2 typo (USE_TYPO_METRICS)"
hmtx = t["hmtx"]
widths = [struct.unpack(">H", hmtx[i * 4:i * 4 + 2])[0] for i in range(n_hmetrics)]
cmap = parse_cmap(t["cmap"])
total = weight = 0.0
missing = []
for ch, f in FREQ.items():
gid = cmap.get(ord(ch), 0)
if gid == 0:
missing.append(ch)
continue
w = widths[gid] if gid < len(widths) else widths[-1]
total += w * f
weight += f
if weight == 0:
raise FontError("font maps none of the Latin sample characters")
return {
"upm": upm, "ascent": asc, "descent": desc, "lineGap": gap, "source": source,
"avg": total / weight, "os2_avg": x_avg_os2, "missing": missing,
}
def pct(x):
return f"{x * 100:.2f}%"
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("webfont")
ap.add_argument("--fallback", default="arial", help="arial|times|georgia|verdana or a font file path")
ap.add_argument("--family", default=None, help="web font family name for the output comment")
ap.add_argument("--index", type=int, default=0, help="font index inside a .ttc collection")
a = ap.parse_args()
try:
web = metrics(a.webfont, a.index)
except (OSError, FontError, struct.error) as exc:
print(f"error: {a.webfont}: {exc}", file=sys.stderr)
return 1
fb_key = a.fallback.lower()
generic = "sans-serif"
if fb_key in PRESETS:
preset = PRESETS[fb_key]
fb_name = preset["name"]
generic = preset["generic"]
if os.path.exists(preset["path"]):
fb = metrics(preset["path"])
fb_avg, fb_upm = fb["avg"], fb["upm"]
elif fb_key in STATIC_AVG:
fb_avg, fb_upm = STATIC_AVG[fb_key], preset["upm"]
print(f"note: {preset['path']} not found; using built-in {fb_name} width", file=sys.stderr)
else:
print(f"error: {preset['path']} not found; pass the fallback font path", file=sys.stderr)
return 1
elif os.path.exists(a.fallback):
try:
fb = metrics(a.fallback)
except (OSError, FontError, struct.error) as exc:
print(f"error: {a.fallback}: {exc}", file=sys.stderr)
return 1
fb_avg, fb_upm = fb["avg"], fb["upm"]
fb_name = os.path.splitext(os.path.basename(a.fallback))[0]
else:
print(f"error: unknown fallback '{a.fallback}'", file=sys.stderr)
return 2
size_adjust = (web["avg"] / web["upm"]) / (fb_avg / fb_upm)
ascent = web["ascent"] / web["upm"] / size_adjust
descent = abs(web["descent"]) / web["upm"] / size_adjust
line_gap = web["lineGap"] / web["upm"] / size_adjust
family = a.family or os.path.splitext(os.path.basename(a.webfont))[0]
print(f"/* web font: {a.webfont}")
print(f" unitsPerEm={web['upm']} ascent={web['ascent']} descent={web['descent']} "
f"lineGap={web['lineGap']} (from {web['source']})")
print(f" weighted avg width={web['avg']:.1f} (OS/2 xAvgCharWidth={web['os2_avg']})")
print(f" fallback: {fb_name} weighted avg width={fb_avg:.1f} upm={fb_upm} */")
if web["missing"]:
print(f"/* warning: web font lacks glyphs for {''.join(web['missing'])!r}; width is approximate */")
print("@font-face {")
print(f' font-family: "{family} Fallback";')
print(f' src: local("{fb_name}");')
print(f" size-adjust: {pct(size_adjust)};")
print(f" ascent-override: {pct(ascent)};")
print(f" descent-override: {pct(descent)};")
print(f" line-gap-override: {pct(line_gap)};")
print("}")
print(f'/* use: font-family: "{family}", "{family} Fallback", {generic}; */')
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.