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.

  • Frontend
  • $web-font-loading
  • 164 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- web-font-loading

Installs to ~/.agents/skills/web-font-loading/ for every project on this machine.

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

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.

~/.agents/skills/web-font-loading/SKILL.md

--- name: web-font-loading description: Optimizes how web fonts load - self-hosting WOFF2, font-display strategy, preloading only the critical face, unicode-range subsetting with… license: MIT ---

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

  1. Read AGENTS.md; find where fonts are declared (@font-face in CSS, <link> to Google Fonts, next/font, @fontsource/*, Nuxt Fonts, Astro fonts).
  2. 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.
  3. 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.
  4. Check licensing before self-hosting or subsetting a commercial font; some licenses forbid modification. Ask the user if unsure.

Decisions

QuestionDefaultEscape hatch
HostSelf-host on your origin (no extra connection, privacy, cache control)Third-party CDN only if license requires it; then preconnect
FormatWOFF2 onlyWOFF for very old embedded browsers
font-displayswap for body and headings, paired with a metric-matched fallbackoptional when the font is decorative or repeat visits matter more than first view; block only for icon fonts (better: SVG icons)
Static vs variableVariable font when using 3+ weights of a familyStatic files for 1–2 weights (often smaller)
PreloadOnly the 1–2 faces used in above-the-fold textNever preload every weight
Subsettingunicode-range split (latin, latin-ext, others)Aggressive subset for a fixed set of glyphs (logos, numerals)

Framework setups

  • Next.js: use next/font/google or next/font/local. It self-hosts at build time, subsets, preloads, and generates a size-adjusted fallback automatically (adjustFontFallback is on by default). Export the font once from a module and apply its className or variable on <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/fonts resolves and self-hosts declared families and generates fallbacks.
  • Plain CSS/HTML: write @font-face yourself 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>
  • crossorigin is required on font preloads even for same-origin files; without it the preload is not reused and the font downloads twice.
  • The preload href must match the src URL byte for byte (including query strings).
  • Declare font-weight and font-style accurately; 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-adjust visually.
  • 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 (tnum for tables, ss0x stylistic sets); the default subset drops many.
  • Split by script with unicode-range so 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.instancer when only a weight range is used.

Caching and delivery

  • Serve with Cache-Control: public, max-age=31536000, immutable and 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-face rules 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: optional can 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-adjust and the metric overrides only on the fallback face; on the web font’s own @font-face they distort the real font.
  • CSS-in-JS that injects @font-face at runtime discovers fonts late; move declarations to static CSS.
  • font-synthesis: none prevents 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.yamlgenerated from this page

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.py201 lines, 8.2 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem