How to build a static fallback pipeline for variable fonts
- Step 1Keep one variable source in the tree — Commit a single variable WOFF2 (or TTF) per family under `src/fonts/`. Every deliverable — the served variable file and each static fallback — derives from it. No duplicated glyph data in source control.
- Step 2Decide default-master vs per-weight fallbacks — If one fallback at the default weight is enough (common), use the freezer / runner — it's a no-Python default-master strip. If legacy users need multiple real weights, use `fonttools varLib.instancer` per weight; the runner freeze would emit identical default-master outlines for every 'weight'.
- Step 3Generate the default-master static via the runner (no Python) — Pair the JAD runner and POST the variable file to `http://127.0.0.1:9789/v1/tools/variable-font-freezer/run`. The runner strips the eight variable tables locally and returns a static TTF. Convert it to WOFF2 in the same step with [TTF→WOFF2](/font-tools/ttf-to-woff2).
- Step 4Generate per-weight statics via fonttools — For each legacy weight, run `fonttools varLib.instancer Family.var.ttf wght=N -o Family-N.ttf`, then convert to WOFF2. This is the only path that bakes real Bold/Light outlines — the browser/runner freeze cannot apply `gvar` deltas.
- Step 5Emit a multi-source @font-face — Order the `src` list: variable WOFF2 first, static WOFF2 fallback(s) after. Use `font-display: swap`. Browsers walk the list and load the first format they can parse — variable for modern, static for legacy.
- Step 6Gate on size and idempotency — Skip regeneration when the source hash is unchanged so re-runs cost nothing. Fail CI if any frozen/instanced fallback exceeds its budget (e.g. 120 KB for a Latin static weight) — that catches an accidental un-subsetted font or a widened axis.
Two ways to make the static fallback
The freezer (and the runner endpoint that wraps it) is the no-Python default-master path. fonttools is the only path that bakes arbitrary instances. Pick per the fallback you need.
| Approach | Runtime | Outlines produced | Use when |
|---|---|---|---|
JAD freezer (runner /v1/tools/variable-font-freezer/run) | Pure JS, local | Default-master only (deltas not applied) | One fallback at the family default weight; no Python in CI |
fonttools varLib.instancer | Python | Real outlines at any axis position | Multiple legacy weights, or any non-default instance |
fonttools varLib.instancer + partial pin | Python | Real outlines, some axes kept | You want to drop opsz but keep wght variable for legacy partial support |
@font-face src ordering
Order matters: the browser stops at the first src it can parse. Variable first, static after. format() hints let unsupported browsers skip a download attempt.
| src position | File | format() hint | Who loads it |
|---|---|---|---|
| 1st | Variable WOFF2 | format("woff2") (or woff2-variations) | Modern browsers (2018+) — they stop here |
| 2nd | Static WOFF2 | format("woff2") | Legacy WOFF2 browsers without variable support |
| 3rd (optional) | Static WOFF | format("woff") | Very old engines lacking WOFF2 |
| last (optional) | Static TTF | format("truetype") | Last-ditch for ancient/embedded engines |
CI step characteristics
What a well-behaved freeze/instance step looks like in a build. The runner path keeps every byte local and enforces the same tier limits as the browser tool.
| Concern | Behaviour | How to achieve it |
|---|---|---|
| Idempotency | Unchanged source → skip regeneration | Hash the source font + options; cache the output by hash |
| Determinism | Same input → same bytes | The freeze is deterministic; pin the runner/fonttools version |
| Locality | Font never leaves the machine | Runner runs on 127.0.0.1; fonttools is local |
| Size gate | Fail build on regression | Compare output bytes to a per-file budget; exit non-zero |
| Tier | Freeze needs Pro | Runner enforces tier; CI account must be Pro+ |
Cookbook
Copy-adaptable build steps. The runner examples assume the JAD runner is paired and listening on 127.0.0.1:9789; the fonttools examples assume pip install fonttools brotli.
Default-master freeze via the runner (no Python)
ExamplePOST the variable file to the freezer endpoint. The runner strips the eight variable tables locally and returns a static TTF. Good for a single default-weight fallback.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/variable-font-freezer/run \
-F 'file=@src/fonts/Inter.var.woff2' \
-F 'inputs={"axisValues":{"wght":400}}' \
-o dist/fonts/Inter.static.ttf
# Note: axisValues are recorded, NOT baked into outlines.
# Output is the default-master static, uncompressed TTF.Per-weight real statics via fonttools
ExampleWhen legacy users need multiple weights with correct outlines, loop varLib.instancer. This is the only path that applies gvar deltas.
for W in 300 400 700; do
fonttools varLib.instancer src/fonts/Inter.var.ttf wght=$W \
-o build/Inter-$W.ttf
done
# build/Inter-300.ttf has real Light outlines, etc.
# Then convert each to WOFF2 for the @font-face fallback.The @font-face block (variable + static fallback)
ExampleVariable first, static after. Modern browsers never download the static; legacy browsers fall through to it.
@font-face {
font-family: "Inter";
font-weight: 100 900; /* variable range */
font-display: swap;
src: url(/fonts/Inter.var.woff2) format("woff2"),
url(/fonts/Inter-static.woff2) format("woff2");
}
/* Legacy engines ignore the range and use the 2nd src. */Idempotent freeze step with a hash cache
ExampleSkip the freeze when the source hasn't changed, so most CI runs add zero time.
SRC=src/fonts/Inter.var.woff2
HASH=$(sha256sum "$SRC" | cut -c1-12)
OUT=dist/fonts/Inter.$HASH.static.ttf
if [ ! -f "$OUT" ]; then
curl -sS -X POST http://127.0.0.1:9789/v1/tools/variable-font-freezer/run \
-F "file=@$SRC" -F 'inputs={}' -o "$OUT"
fiSize-budget gate
ExampleFail the build if a frozen fallback regresses past its budget — catches an un-subsetted source or a widened axis range silently bloating the static.
BUDGET=125000 # 125 KB for a Latin static weight SIZE=$(wc -c < dist/fonts/Inter-static.woff2) if [ "$SIZE" -gt "$BUDGET" ]; then echo "Static fallback $SIZE B exceeds budget $BUDGET B" >&2 exit 1 fi
Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
You looped the freezer per weight expecting different outlines
Wrong tool — use instancerCalling /v1/tools/variable-font-freezer/run with wght=300, then wght=700, then wght=400 produces three files with identical default-master outlines — only the recorded Axes baked metric differs. The freeze never applies gvar deltas. For distinct per-weight outlines you must use fonttools varLib.instancer.
Static src listed before variable
Bug — modern browsers get the staticIf the static WOFF2 comes first in the src list, modern browsers stop there and you serve the legacy fallback to everyone — losing the variable benefit entirely. Variable WOFF2 must be first. The format() hints don't fix the order; they only let a browser skip a format it definitely can't parse.
Frozen output ships as uncompressed TTF by mistake
Fail — bloated payloadThe freezer/runner returns an uncompressed TTF. Wiring that directly into @font-face ships 2–3× the bytes a WOFF2 would. Always add a TTF→WOFF2 conversion step after the freeze. The size gate will catch this if you set the budget against a WOFF2-sized expectation.
CI account is Free, freeze 403s
Rejected — Pro requiredThe freezer is Pro-gated, and the runner enforces tier. A Free CI account can't run it. Use a Pro+ account for the build, or pre-generate the statics once locally and commit them. (Reads like axis inspection are free; the write is not.)
Source is an OTF — fallback flavor mismatch
Expected — rename in the stepFreezing a CFF2 variable OTF yields an OTTO-flavored file named .static.ttf. Browsers load it via the format() hint, but a stricter CDN or installer keying off the extension may balk. Rename to .otf and set format("opentype") in the src for that file.
head.checkSumAdjustment not recomputed in CI artifacts
By design — sanitize if your CDN validatesThe freeze rewrites the directory without recomputing head.checkSumAdjustment. Browsers ignore it, but some font-serving CDNs run ots-sanitize and may warn. Add an ots-sanitize (or fonttools ttx round-trip) post-step if your delivery pipeline validates checksums.
Re-run produces different bytes (non-idempotent)
Investigate — version driftThe freeze itself is deterministic. If two CI runs produce different output bytes for the same source, the runner or fonttools version changed between runs. Pin the runner version and the fonttools package version, and key your hash cache on (source hash + tool version) so a version bump invalidates correctly.
Italic family forgotten
Common omissionMost families ship italic as a separate variable file. If your pipeline freezes only the upright, legacy users get no italic fallback and the browser synthesises an oblique. Run the same freeze/instance step against the italic variable source and add a font-style: italic @font-face block with its own src list.
MVAR metrics drift between variable and static fallback
Expected at non-default positionsAt the default position the static matches the variable. But if your CSS sets the variable to a non-default opsz/wght with MVAR-driven metric compensation, the static fallback (which reverts to head/OS/2 metrics) can line-break differently. Test legacy line wrapping; adjust line-height in the legacy @font-face cascade if it diverges.
Frequently asked questions
How does the browser pick variable vs static?
By walking the @font-face src list in order and loading the first entry whose format() it can parse and whose file it can decode. List the variable WOFF2 first; modern browsers load it and stop. Legacy browsers that can't use the variable file fall through to the static WOFF2 you list next.
Can the JAD freezer produce all my legacy weights?
Only as identical default-master copies — it doesn't apply gvar deltas, so every 'weight' would render the same. Use the freezer for a single default-weight fallback. For multiple real weights, use fonttools varLib.instancer wght=N per weight, then convert to WOFF2.
Do I run this in the browser or in CI?
The browser tool is for one-offs. For a build, drive the JAD runner over its local HTTP API (http://127.0.0.1:9789/v1/tools/variable-font-freezer/run) so it's scriptable and the font stays on the build machine. Wrap fonttools alongside it for per-weight outlines.
Should I include the format("woff2-variations") hint?
It's optional. format("woff2") works fine; woff2-variations is more precise but older browsers without variable support may still attempt the download before failing over. Either way the fallback ordering does the real work. Don't rely on the hint alone to gate variable vs static.
What about the italic weights?
Italic is usually a separate variable file. Run the identical freeze (default master) or instancer (per weight) step against the italic source, then add a font-style: italic @font-face block with its own variable-first, static-after src list. Don't let the browser synthesise oblique from the upright.
Is there a CI time cost?
Per font the freeze is fast (the runner does a single in-memory directory rewrite), and an idempotent hash cache means unchanged sources skip it entirely. The fonttools instancer step is heavier per weight but still seconds. Cache by (source hash + tool version) and most CI runs add effectively zero time.
Do I need to convert the frozen TTF to WOFF2?
Yes, for the web. The freezer returns an uncompressed TTF; serving that directly ships 2–3× the bytes. Add a TTF→WOFF2 step (browser tool or runner) after the freeze and put the WOFF2 in your @font-face fallback src.
Does the freeze need Pro in CI?
Yes — the freezer is Pro-gated and the runner enforces tier, so the CI account must be Pro or higher. Axis inspection (reading fvar) is free, but writing the static file is not. Alternatively, generate the statics once locally and commit them.
Will the static fallback have the same kerning and ligatures?
Yes. The freeze removes only variable tables; GSUB, GPOS, and kern are preserved. Verify on the output with the OpenType features inspector and the kerning-pair auditor before shipping the fallback.
How do I keep the build deterministic?
Pin the runner version and the fonttools version, and key your output cache on the source-font hash plus the tool version. The freeze and instance operations are deterministic for a fixed tool version, so the same source yields the same bytes — which keeps your CDN cache and your diffs stable.
What size budget should the gate use?
Set it against the WOFF2 size you expect for a subset static weight — for a Latin subset, something like 100–130 KB is a reasonable ceiling. The point is to catch regressions: an un-subsetted source, a forgotten subset step, or a widened axis range silently inflating the fallback. Tune per family.
How do I validate the whole pipeline output?
Run the frozen/instanced WOFF2 through a sanitizer (ots-sanitize) to catch the un-recomputed head checksum, confirm metrics with the font metrics analyzer, and load the @font-face in a real legacy engine if you have one. Then assert the size gate. See the edge-cases reference for the failure modes to test for.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.