Web Font Loading skill for Codex
Fast fonts, zero layout shift on swap. Codex inventories every font file a page loads, cuts faces the design does not need, self-hosts WOFF2 subsets with unicode-range, preloads only above-the-fold faces with the crossorigin attribute, and pairs font-display swap with a metric-matched fallback. A bundled standard-library script reads the font file and prints size-adjust and ascent-override values, so the swap no longer reflows text.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- web-font-loading Installs to ~/.agents/skills/web-font-loading/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install web-font-loading The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project web-font-loading Installs to .agents/skills/web-font-loading/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/web-font-loading.zipunzip -o web-font-loading.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) web-font-loading PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $web-font-loading, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Optimizes how web fonts load - self-hosting WOFF2, font-display strategy, preloading only the critical face, unicode-range subsetting with pyftsubset, variable fonts, and metric-matched fallbacks with size-adjust and ascent-override to remove layout shift - including next/font and Fontsource setups. Use when text flashes or jumps when fonts load, Lighthouse flags font-display or CLS from fonts, fonts slow LCP, a site loads Google Fonts from the CDN, or a user wants fewer or smaller font files.
Use it when
- Text flashes, is invisible, or reflows when web fonts load
- Lighthouse reports font-display or font-related CLS issues
- A site loads fonts from Google Fonts or several weights it does not use
- Fonts are large, unsubsetted TTF or WOFF files
Use something else when
- Choosing typefaces, scales and pairings: use typography-systems
- Page-wide LCP, INP and CLS diagnosis beyond fonts: use core-web-vitals
How to check the result
Block the font URL in DevTools and compare line breaks with and without it, check that preloaded fonts download once, and confirm CLS and total font bytes against the baseline.
The mistake it prevents
Preloading a font without the crossorigin attribute, which downloads it twice, or preloading every weight and delaying the LCP image.
Done well means: text is visible immediately, the swap to the web font causes no measurable layout shift, only the faces used above the fold are requested early, files are WOFF2 subsets served from your own origin with long cache lifetimes, and the font count is the minimum the design needs.
Before changing anything
- Read
AGENTS.md; find where fonts are declared (@font-facein CSS,<link>to Google Fonts,next/font,@fontsource/*, Nuxt Fonts, Astro fonts). - Inventory what actually loads: DevTools Network, filter “Font”. Note each file’s family, weight, style, format, size and initiator. Count faces; more than 4–6 is a design question.
- Record a baseline: Lighthouse (CLS, LCP) and a Performance trace on a throttled connection (Slow 4G) to see when fonts arrive relative to first paint.
- Check licensing before self-hosting or subsetting a commercial font; some licenses forbid modification. Ask the user if unsure.
Decisions
| Question | Default | Escape hatch |
|---|---|---|
| Host | Self-host on your origin (no extra connection, privacy, cache control) | Third-party CDN only if license requires it; then preconnect |
| Format | WOFF2 only | WOFF for very old embedded browsers |
font-display | swap for body and headings, paired with a metric-matched fallback | optional when the font is decorative or repeat visits matter more than first view; block only for icon fonts (better: SVG icons) |
| Static vs variable | Variable font when using 3+ weights of a family | Static files for 1–2 weights (often smaller) |
| Preload | Only the 1–2 faces used in above-the-fold text | Never preload every weight |
| Subsetting | unicode-range split (latin, latin-ext, others) | Aggressive subset for a fixed set of glyphs (logos, numerals) |
Framework setups
- Next.js: use
next/font/googleornext/font/local. It self-hosts at build time, subsets, preloads, and generates a size-adjusted fallback automatically (adjustFontFallbackis on by default). Export the font once from a module and apply itsclassNameorvariableon<html>; do not call the loader in many files. - Fontsource (
@fontsource-variable/inter): import in the entry CSS/JS; it ships WOFF2 with unicode-range splits. Add your own fallback face (below). - Nuxt:
@nuxt/fontsresolves and self-hosts declared families and generates fallbacks. - Plain CSS/HTML: write
@font-faceyourself as below.
Downloading fonts or packages needs network; ask for approval in a sandbox.
@font-face template
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-var.woff2") format("woff2");
font-weight: 100 900; /* range = variable font */
font-style: normal;
font-display: swap;
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+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Metric-matched fallback: generated by scripts/fallback_metrics.py */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 109.32%;
ascent-override: 88.62%;
descent-override: 22.06%;
line-gap-override: 0%;
}
:root { --font-sans: "Inter", "Inter Fallback", system-ui, sans-serif; }
body { font-family: var(--font-sans); }
<link rel="preload" href="/fonts/inter-latin-var.woff2" as="font" type="font/woff2" crossorigin>
crossoriginis required on font preloads even for same-origin files; without it the preload is not reused and the font downloads twice.- The preload
hrefmust match thesrcURL byte for byte (including query strings). - Declare
font-weightandfont-styleaccurately; mismatches trigger faux bold/italic synthesis, which looks wrong and shifts layout when the real face loads.
Metric-matched fallbacks
The layout shift on swap comes from the fallback having different glyph widths and vertical metrics. Override the fallback’s metrics so it occupies the same space:
python3 scripts/fallback_metrics.py public/fonts/Inter-Regular.woff --family Inter
python3 scripts/fallback_metrics.py fonts/Lora.ttf --family Lora --fallback times
python3 scripts/fallback_metrics.py fonts/Brand.ttf --family Brand --fallback-file /path/to/Arial.ttf
The script reads TTF, OTF or WOFF (not WOFF2: decompress first, or point it at the source
TTF), uses hhea metrics (or OS/2 typo metrics when the font sets USE_TYPO_METRICS), weights
glyph widths by English letter frequency, and prints a ready @font-face block. Arial, Times
New Roman and Georgia averages are built in; --fallback-file measures any local font.
- Use one fallback face per web font family; for very different weights (e.g. a 900 display face) generate a separate fallback with that face’s file.
- For non-Latin text, weighting by English letters is wrong; tune
size-adjustvisually. - Pick a fallback that exists on target platforms: Arial (Windows, macOS), plus Roboto or
DejaVu Sans/Liberation Sans on Android/Linux. List several
local()names if needed.
Subsetting
pip install fonttools brotli # network needed
pyftsubset Inter.ttf \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC,U+2122" \
--layout-features="kern,liga,calt,ss01,tnum" \
--flavor=woff2 --output-file=inter-latin.woff2
- Keep OpenType features the design uses (
tnumfor tables,ss0xstylistic sets); the default subset drops many. - Split by script with
unicode-rangeso pages only download ranges they render. Do not subset away characters from user-generated content (names, addresses) unless a broader range face also exists. - Variable fonts: drop unused axes or pin ranges with
fonttools varLib.instancerwhen only a weight range is used.
Caching and delivery
- Serve with
Cache-Control: public, max-age=31536000, immutableand hashed filenames. Content-Type: font/woff2; enable CORS (Access-Control-Allow-Origin) when fonts are on a different origin than the page.- Remove unused
@font-facerules and old formats (TTF, EOT, SVG fonts) from CSS.
Gotchas
- Google Fonts
<link>CSS is render-blocking and adds two extra origins; self-hosting with the same files is faster and avoids sending visitor IPs to a third party. font-display: optionalcan show the fallback for the entire first visit on slow networks; acceptable only with good fallback metrics.- Icon fonts render squares or letters during loading and are poorly accessible; migrate to SVG icons when touching them.
- Put
size-adjustand the metric overrides only on the fallback face; on the web font’s own@font-facethey distort the real font. - CSS-in-JS that injects
@font-faceat runtime discovers fonts late; move declarations to static CSS. font-synthesis: noneprevents fake bold/italic when a face is missing, which exposes missing files instead of hiding them.
Verification
- Network panel: only expected WOFF2 files load; preloaded ones show no duplicate request and no “preloaded but not used” console warning.
- Toggle the web font off (block the font URL in DevTools) and on: paragraphs and headings keep nearly identical line breaks and height.
- Performance trace on Slow 4G: text paints before fonts arrive; layout-shift track shows no shift attributed to text when fonts swap.
- Lighthouse: no “Ensure text remains visible during webfont load”; CLS ≤ 0.1.
- Total font bytes for the first view, before vs after.
Output
Report: faces kept and removed, total font bytes before and after, preloads added,
font-display choice per family, fallback overrides (paste the generated block), and the
CLS/LCP change measured.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Web Font Loading"
short_description: "Fast fonts, zero layout shift on swap"
default_prompt: "$web-font-loading Text jumps when our Google Font loads, stop the layout shift" scripts/fallback_metrics.py
#!/usr/bin/env python3
"""Compute metric-matched fallback @font-face overrides for a web font.
Reads a TTF, OTF or WOFF (1.0) file with the standard library only, measures its
vertical metrics and a letter-frequency-weighted average glyph width, and prints a
CSS @font-face block that makes a local fallback font (Arial by default) occupy
the same space. This shrinks layout shift when the web font swaps in.
Usage:
python3 fallback_metrics.py path/to/Font.woff [--family "Inter"] \
[--fallback arial|times|georgia] [--fallback-file /path/to/Local.ttf]
WOFF2 needs Brotli, which is not in the standard library: decompress first, e.g.
`python3 -m fontTools.ttLib.woff2 decompress Font.woff2` (fonttools), or use the
TTF the WOFF2 was built from.
Exit code 0 on success, 1 on any error.
"""
import argparse
import struct
import sys
import zlib
# Relative frequency of characters in English running text (space included).
# Used to weight glyph advances so the average reflects real paragraphs.
FREQ = {
" ": 0.1800, "e": 0.1020, "t": 0.0740, "a": 0.0660, "o": 0.0620, "i": 0.0570,
"n": 0.0570, "s": 0.0530, "h": 0.0500, "r": 0.0490, "d": 0.0350, "l": 0.0330,
"u": 0.0230, "c": 0.0230, "m": 0.0200, "w": 0.0190, "f": 0.0180, "g": 0.0160,
"y": 0.0160, "p": 0.0150, "b": 0.0120, "v": 0.0080, "k": 0.0060, "j": 0.0010,
"x": 0.0010, "q": 0.0008, "z": 0.0007, ",": 0.0100, ".": 0.0090, "T": 0.0030,
"I": 0.0030, "A": 0.0025, "S": 0.0020, "C": 0.0015, "M": 0.0012,
}
# Built-in fallback metrics, measured with this script's own method from the
# macOS system copies of each font. Pass --fallback-file to measure your own.
# Values are weighted average advance / unitsPerEm.
BUILTIN = {
"arial": {"local": "Arial", "avg": 0.4403},
"times": {"local": "Times New Roman", "avg": 0.3997},
"georgia": {"local": "Georgia", "avg": 0.4382},
}
class FontError(Exception):
pass
def read_tables(data: bytes) -> dict:
sig = data[:4]
tables = {}
if sig == b"wOF2":
raise FontError("WOFF2 is Brotli-compressed; decompress to TTF first (see --help)")
if sig == b"ttcf":
raise FontError("font collections (.ttc) are not supported; extract one face first")
if sig == b"wOFF":
num = struct.unpack(">H", data[12:14])[0]
for i in range(num):
off = 44 + i * 20
tag, offset, comp_len, orig_len, _ = struct.unpack(">4sIIII", data[off:off + 20])
raw = data[offset:offset + comp_len]
tables[tag.decode("latin-1")] = zlib.decompress(raw) if comp_len < orig_len else raw
return tables
if sig in (b"\x00\x01\x00\x00", b"OTTO", b"true"):
num = struct.unpack(">H", data[4:6])[0]
for i in range(num):
off = 12 + i * 16
tag, _, offset, length = struct.unpack(">4sIII", data[off:off + 16])
tables[tag.decode("latin-1")] = data[offset:offset + length]
return tables
raise FontError("not a TTF, OTF or WOFF file (unknown signature %r)" % sig)
def parse_cmap(cmap: bytes) -> dict:
"""Return {codepoint: glyph_id} from a format 4 or 12 Unicode subtable."""
num = struct.unpack(">H", cmap[2:4])[0]
best = None
for i in range(num):
pid, eid, off = struct.unpack(">HHI", cmap[4 + i * 8:12 + i * 8])
fmt = struct.unpack(">H", cmap[off:off + 2])[0]
if (pid, eid) in ((3, 10), (0, 4), (0, 6)) and fmt == 12:
best = (off, 12)
break
if (pid, eid) in ((3, 1), (0, 3), (0, 1), (0, 2)) and fmt == 4 and best is None:
best = (off, 4)
if best is None:
raise FontError("no Unicode cmap subtable (format 4 or 12) found")
off, fmt = best
mapping = {}
wanted = {ord(c) for c in FREQ}
if fmt == 12:
n_groups = struct.unpack(">I", cmap[off + 12:off + 16])[0]
for g in range(n_groups):
start, end, gid = struct.unpack(">III", cmap[off + 16 + g * 12:off + 28 + g * 12])
for cp in wanted:
if start <= cp <= end:
mapping[cp] = gid + cp - start
return mapping
seg_x2 = struct.unpack(">H", cmap[off + 6:off + 8])[0]
segs = seg_x2 // 2
ends_at = off + 14
starts_at = ends_at + seg_x2 + 2
delta_at = starts_at + seg_x2
range_at = delta_at + seg_x2
for s in range(segs):
end = struct.unpack(">H", cmap[ends_at + s * 2:ends_at + s * 2 + 2])[0]
start = struct.unpack(">H", cmap[starts_at + s * 2:starts_at + s * 2 + 2])[0]
delta = struct.unpack(">h", cmap[delta_at + s * 2:delta_at + s * 2 + 2])[0]
ro_pos = range_at + s * 2
ro = struct.unpack(">H", cmap[ro_pos:ro_pos + 2])[0]
for cp in wanted:
if start <= cp <= end:
if ro == 0:
gid = (cp + delta) & 0xFFFF
else:
gpos = ro_pos + ro + (cp - start) * 2
gid = struct.unpack(">H", cmap[gpos:gpos + 2])[0]
if gid:
gid = (gid + delta) & 0xFFFF
mapping[cp] = gid
return mapping
def measure(path: str) -> dict:
try:
with open(path, "rb") as fh:
data = fh.read()
except OSError as exc:
raise FontError(str(exc))
t = read_tables(data)
for need in ("head", "hhea", "hmtx", "cmap"):
if need not in t:
raise FontError("missing required table '%s'" % need)
upm = struct.unpack(">H", t["head"][18:20])[0]
asc, desc, gap = struct.unpack(">hhh", t["hhea"][4:10])
n_hmetrics = struct.unpack(">H", t["hhea"][34:36])[0]
if "OS/2" in t and len(t["OS/2"]) >= 78:
os2 = t["OS/2"]
fs_selection = struct.unpack(">H", os2[62:64])[0]
if fs_selection & (1 << 7): # USE_TYPO_METRICS
asc, desc, gap = struct.unpack(">hhh", os2[68:74])
hmtx = t["hmtx"]
def advance(gid: int) -> int:
idx = min(gid, n_hmetrics - 1)
return struct.unpack(">H", hmtx[idx * 4:idx * 4 + 2])[0]
cmap = parse_cmap(t["cmap"])
total_w = 0.0
total_f = 0.0
for ch, f in FREQ.items():
gid = cmap.get(ord(ch), 0)
if gid:
total_w += advance(gid) * f
total_f += f
if total_f < 0.5:
raise FontError("font lacks most basic Latin glyphs; metric matching is not meaningful")
return {"upm": upm, "ascent": asc, "descent": desc, "lineGap": gap,
"avg": total_w / total_f / upm}
def pct(x: float) -> str:
return ("%.2f" % (x * 100)).rstrip("0").rstrip(".") + "%"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("font")
ap.add_argument("--family", default="Web Font", help="web font family name used in CSS")
ap.add_argument("--fallback", default="arial", choices=sorted(BUILTIN))
ap.add_argument("--fallback-file", help="measure this local font instead of built-in values")
args = ap.parse_args()
try:
web = measure(args.font)
if args.fallback_file:
fb_avg = measure(args.fallback_file)["avg"]
fb_name = args.fallback_file.rsplit("/", 1)[-1].rsplit(".", 1)[0]
else:
fb_avg = BUILTIN[args.fallback]["avg"]
fb_name = BUILTIN[args.fallback]["local"]
except FontError as exc:
print("error: %s" % exc, file=sys.stderr)
return 1
size_adjust = web["avg"] / fb_avg
upm = web["upm"]
print("/* %s: upm=%d ascent=%d descent=%d lineGap=%d avgWidth=%.4fem; fallback avg=%.4fem */"
% (args.font, upm, web["ascent"], web["descent"], web["lineGap"], web["avg"], fb_avg))
print("@font-face {")
print(' font-family: "%s Fallback";' % args.family)
print(' src: local("%s");' % fb_name)
print(" size-adjust: %s;" % pct(size_adjust))
print(" ascent-override: %s;" % pct(web["ascent"] / upm / size_adjust))
print(" descent-override: %s;" % pct(abs(web["descent"]) / upm / size_adjust))
print(" line-gap-override: %s;" % pct(web["lineGap"] / upm / size_adjust))
print("}")
generic = "serif" if args.fallback in ("times", "georgia") else "sans-serif"
print('/* use: font-family: "%s", "%s Fallback", %s; */' % (args.family, args.family, generic))
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.