How to set a variable font policy for your design system
- Step 1Set the browser floor explicitly — Read your real analytics. Decide the oldest engine you support and write it down (e.g. 'Chrome/Firefox/Safari last 2 years; no IE11'). The floor decides whether you need static fallbacks at all — variable needs 2018+ engines.
- Step 2Write the variable-vs-static rule — Codify the byte rule: variable-first for any family used at 3+ weights or with a second axis; static for 1–2 weights. Reference [the tradeoffs guide](/font-tools/guides/variable-vs-static-fonts-tradeoffs) so the reasoning is on record, not folklore.
- Step 3Pin the fallback-generation method — State which tool produces fallbacks for each case. Default-weight fallback: [the freezer](/font-tools/variable-font-freezer) (or its runner endpoint) for a default-master static. Multiple legacy weights with real outlines: `fonttools varLib.instancer`. Make the distinction explicit so nobody ships identical 'weights'.
- Step 4Standardise the @font-face boilerplate — Provide the canonical block: variable WOFF2 first, static WOFF2 fallback after, `font-display: swap`, weight range on the variable entry. Put it in the system docs so every team copies the same thing.
- Step 5Document the build + test matrix — Reference the build script ([the fallback pipeline guide](/font-tools/guides/variable-font-fallback-static-build-pipeline)) and the verification steps: features-inspector for `GSUB`/`GPOS`, metrics-analyzer for `MVAR` reversion, a sanitizer for the `head` checksum, and a legacy-engine smoke test.
- Step 6Define the rollback — Keep the static set in source control alongside the variable source. If a variable regression appears in production, the policy's rollback is to point `@font-face` at the static set — a one-line change, not a re-build.
Policy decision by audience profile
The policy should fall out of your audience and usage, not a default. These are the common profiles and the call each implies.
| Audience / usage profile | Recommended policy | Fallback method |
|---|---|---|
| ~99% modern, 3+ weights per family | Variable-only | None needed; keep statics in VC as rollback |
| Mostly modern, some legacy webviews | Variable-first + static fallback | Freezer default-master for the default weight |
| Multiple legacy weights required | Variable-first + per-weight statics | fonttools varLib.instancer per weight |
| 1–2 weights, weight-only | Static-only | N/A (no variable shipped) |
| Heavy CJK | Subset-first; variable optional | Subset, then freeze the subset if a fallback is needed |
| Locked-down intranet on IE11 | Static-only | Per-weight statics via fonttools |
Who owns what in the policy
A policy is only enforceable if ownership is clear. Map each artifact to a tool and an owner.
| Artifact | Tool / source | Typical owner |
|---|---|---|
| Variable source font | Foundry / Google Fonts, committed to src/fonts/ | Design / Brand |
| Default-weight static fallback | Freezer / runner | Build / Platform |
| Per-weight statics | fonttools varLib.instancer | Build / Platform |
| Web-ready WOFF2 | TTF→WOFF2 | Build / Platform |
@font-face boilerplate | font-face generator | Design System |
| Verification matrix | Features inspector + metrics analyzer + sanitizer | QA / Platform |
What the freezer can and can't contribute to the policy
Set expectations so the policy doesn't over-promise. The freezer is a default-master tool, not a general instancer.
| Policy need | Freezer covers it? | If not, use… |
|---|---|---|
| Single default-weight static fallback | Yes | — |
| No-Python CI fallback generation | Yes (runner endpoint) | — |
| Multiple legacy weights with real outlines | No | fonttools varLib.instancer |
| Pin a non-default instance (Bold static) | No | fonttools varLib.instancer |
| Trim unused axis ranges (keep variable) | No (it removes all axes) | Desktop subsetter / fonttools instancer with partial pin |
| Web-ready compressed output | No (emits TTF) | TTF→WOFF2 |
Cookbook
Drop-in policy text and boilerplate. Adapt the thresholds and browser floor to your org; the structure is what matters.
Policy statement: variable-first with default-master fallback
ExampleThe most common 2026 policy. Variable to modern, a single default-weight static to legacy, generated by the freezer.
TYPOGRAPHY FORMAT POLICY - Browser floor: last 2 versions of Chrome/Firefox/Safari/Edge. - Families used at 3+ weights or with a 2nd axis: VARIABLE WOFF2. - Legacy fallback: ONE default-weight static (freezer default-master) -> TTF->WOFF2, listed after the variable in @font-face src. - Per-weight legacy statics: NOT shipped (floor excludes IE11). - Rollback: static set committed under src/fonts/static/.
Policy statement: per-weight legacy support
ExampleFor an org that must support a legacy weight set with real outlines (e.g. an enterprise product with old webviews).
LEGACY-WEIGHT POLICY - Variable WOFF2 for modern engines (variable-first). - Legacy statics: Light/Regular/Bold via fonttools varLib.instancer (REAL outlines; freezer cannot bake non-default weights). - Each static -> WOFF2 -> @font-face fallback, weight-matched. - CI: instancer step + size gate; runner reserved for default-master only.
Canonical @font-face boilerplate
ExampleThe single block every team copies. Variable first, static fallback after, swap display.
@font-face {
font-family: "DS Sans";
font-weight: 100 900;
font-display: swap;
src: url(/fonts/ds-sans.var.woff2) format("woff2"),
url(/fonts/ds-sans-regular.woff2) format("woff2");
}
/* Generate the fallback per the pinned method; never reorder. */Migration checklist (static-only → variable-first)
ExampleThe reversible migration. Source the variable font, swap the @font-face, regenerate fallbacks, run regression, keep statics for rollback.
[ ] Source variable font (often free for major families) [ ] Replace per-weight @font-face with one variable block [ ] Generate default-master static fallback (freezer) [ ] Visual regression across key pages [ ] Keep old statics in VC -> instant rollback [ ] Update the design-system docs with the new policy
CJK exception clause
ExampleCJK doesn't shrink from freezing — subset first. The policy should say so explicitly so nobody freezes a 10 MB CJK font expecting savings.
CJK CLAUSE - Freezing CJK saves little (glyph-dominated, small gvar). - Required step: subset to in-use characters (font-subsetter) BEFORE any freeze. - Variable CJK optional; static fallback only if browser floor needs it.
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.
Policy says 'freeze per weight' for legacy support
Wrong — produces identical weightsA policy that tells teams to freeze each legacy weight will silently ship identical default-master outlines for every 'weight', because the freezer doesn't apply gvar deltas. The policy must specify fonttools varLib.instancer for any per-weight or non-default static. Reserve the freezer for the single default-master fallback.
No browser floor written down
Gap — fallback becomes accidentalWithout an explicit floor, every team guesses whether a static fallback is needed, and the system drifts. Write the floor as a concrete statement ('last 2 versions; no IE11') so the fallback requirement is a deliberate yes/no, not a per-feature debate.
Variable file on the critical path with no budget
Risk — LCP regressionA policy that mandates variable-first without a size budget invites someone to put a 300 KB variable file render-blocking on a landing page. Add a per-page critical-font budget and allow a small static subset for above-the-fold text where the variable file is too heavy.
Italic omitted from the policy
Common gapMost families ship italic separately. A policy that only addresses the upright leaves teams to synthesise oblique or guess. State that italic gets the same variable-first + fallback treatment, generated from the italic variable source, with its own font-style: italic block.
OTF flavor mismatch not addressed
Operational gotchaFreezing a CFF2 OTF yields an OTTO-flavored file named .static.ttf. If the policy's pipeline serves it without renaming, an extension-checking CDN or installer may reject it. The policy's build step should rename OTF-flavored output to .otf and use format("opentype").
Checksum-validating CDN rejects frozen fallback
Operational gotchaThe freezer doesn't recompute head.checkSumAdjustment. A delivery pipeline that validates checksums may warn or reject. The policy should include an ots-sanitize post-step for any frozen artifact that flows through a validating CDN.
MVAR metric drift breaks a tight layout
Expected at non-default positionsIf the design uses a non-default opsz/wght with MVAR metric compensation, the static fallback (metrics reverted to defaults) can wrap text differently and break a pixel-tight component. The policy's verification matrix should include a metrics check and a legacy line-wrap smoke test.
No rollback artifact kept
Risk — slow recoveryIf the migration deletes the old statics, recovering from a variable-font regression means a rebuild under pressure. The policy must require keeping the static set in source control so rollback is a one-line @font-face swap, not an emergency build.
CJK family frozen expecting savings
Expected — little benefitFreezing a CJK variable font barely shrinks it because it's glyph-dominated. A policy without a CJK clause leads to wasted effort and disappointment. State that CJK must be subset first; freezing is only for a legacy fallback, not a size optimisation.
Frequently asked questions
Should everyone standardise on variable-first?
Most should, but not all. If your audience is overwhelmingly legacy (a rare IE11-only intranet), static-only is simpler and variable adds nothing. If it's overwhelmingly modern, variable-first with no fallback is fine. The policy's job is to make that an explicit, audience-driven decision rather than a default everyone copies blindly.
What's the right fallback-generation method to put in the policy?
Two methods, by case. A single default-weight fallback: the freezer (or its runner endpoint) for a default-master static — fast, no Python. Multiple legacy weights with real outlines: fonttools varLib.instancer per weight. Pin both explicitly so teams don't freeze per-weight and ship identical 'weights'.
Does Figma support variable fonts?
Yes, since 2022 — it reads axis values and exposes named instances in the picker. Designer tooling is no longer a reason to avoid variable. The policy can assume designers and developers share the same axis flexibility; the remaining constraints are the browser floor and pipeline compatibility.
What's the migration cost from static-only to variable-first?
Typically 1–2 sprints: source the variable font (often free for major families), replace per-weight @font-face blocks with one variable block, generate fallbacks, and run visual regression. Keep the old statics for rollback. Teams commonly report meaningful bandwidth savings on multi-weight families post-migration.
Can we revert if variable goes wrong in production?
Yes, if the policy required keeping the static set in source control. Then rollback is a one-line @font-face change pointing at the statics — no rebuild. That's exactly why 'keep the rollback artifact' belongs in the policy text, not in someone's memory.
How do we handle CJK in the policy?
With an explicit clause: freezing CJK saves little because it's glyph-dominated, so the required first step is subsetting to in-use characters with the font subsetter. Ship variable CJK only if it earns its bytes, and freeze only the subset if a legacy fallback is actually needed.
What goes in the verification matrix?
Confirm GSUB/GPOS/kern survived with the OpenType features inspector; check MVAR metric reversion with the metrics analyzer; run ots-sanitize for the un-recomputed head checksum; and smoke-test the @font-face in a legacy engine. Make these gates, not suggestions.
Does the freezer need Pro, and does that affect the policy?
Yes — the freeze is Pro-gated, and the runner enforces tier, so a CI account doing freezes must be Pro+. The policy should note this so the build account is provisioned correctly. Axis inspection (reading the design space) is free; only writing the static needs Pro.
Should the policy mandate WOFF2 output?
Yes. The freezer emits an uncompressed TTF, so the policy's pipeline must include a TTF→WOFF2 step for web deliverables. Serving the raw TTF ships 2–3× the bytes. State WOFF2 as the web target and the TTF as an intermediate only.
How often should the policy be revisited?
Whenever your browser floor moves or your weight usage changes materially. Codify a review cadence (e.g. each major-version planning cycle) so the variable-vs-static call and the fallback method stay aligned with reality instead of ossifying.
Can the freezer trim unused axis ranges instead of removing all of them?
No — it removes all variable tables, collapsing the font to the default master. There's no partial-axis or range-trim mode in the freezer. If your goal is to keep the font variable but smaller (trim wght to a used range), that's a desktop fonttools operation (instancer with a partial pin), not a freeze.
Where do the other guides fit into the policy?
Use the tradeoffs guide for the variable-vs-static reasoning, the fallback pipeline guide for the build/CI implementation, the freeze how-to for the basic operation, and the edge-cases reference for the failure modes your verification matrix should 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.