How to variable font freezing edge cases and gotchas
- Step 1Inspect the design space before freezing — Read the axis list and named instances with [the OpenType features inspector](/font-tools/opentype-features-inspector) (free). Note standard axes (`wght`, `wdth`, `slnt`, `ital`, `opsz`) and custom ones (uppercase 4-char tags like `GRAD`, `YOPQ`, `XOPQ`, `FILL`). Knowing what's there tells you what freezing will collapse.
- Step 2Decide if the default master is the instance you want — Check the default values per axis. If your target is the default (often `wght=400`), the freezer gives you exactly the right outlines. If your target is anything else, the freezer's output will look like the default — plan to use `fonttools varLib.instancer` instead.
- Step 3Freeze and read the panel — Run the freeze. The result panel's `Frozen at default` line is the truth: `yes` means outlines = the instance you asked for; `no — visual outlines still at default` means the file is static but the shapes are the default master, not your typed values.
- Step 4Handle the flavor and checksum — If the source was a CFF2 OTF, rename the output to `.otf`. If your downstream validates checksums, run `ots-sanitize` or a `fonttools ttx` round-trip to recompute `head.checkSumAdjustment`.
- Step 5Verify metrics and features survived — Confirm `GSUB`/`GPOS`/`kern` with [the features inspector](/font-tools/opentype-features-inspector) and check that metrics look right with [the metrics analyzer](/font-tools/font-metrics-analyzer) — especially for fonts that used `MVAR` to adjust metrics off-default.
- Step 6Convert to WOFF2 for the web — The output is uncompressed TTF. For a web deliverable, run it through [TTF→WOFF2](/font-tools/ttf-to-woff2). For comparison against the original variable WOFF2, compare WOFF2-to-WOFF2, never TTF-to-WOFF2.
Edge case → behaviour → fix
Each row is a real behaviour of the JAD freezer, grounded in lib/font/font-processor.ts. 'Behaviour' is what the tool actually does; 'fix' is the correct next step.
| Case | Behaviour | Fix / next step |
|---|---|---|
| Non-default axis value typed | Recorded in Axes baked; outlines stay at default master | Use fonttools varLib.instancer for real baked outlines |
| CFF2 OTF input | Output keeps OTTO flavor but is named .static.ttf | Rename to .otf; use format("opentype") in @font-face |
head checksum | checkSumAdjustment not recomputed | Browsers ignore it; ots-sanitize to satisfy strict validators |
MVAR present | Dropped; metrics revert to head/OS/2 defaults | Fine at default position; test line-setting off-default |
| CJK variable font | Strips variable tables but glyph bulk remains | Subset with the font subsetter; freezing alone saves little |
Non-variable input (no fvar) | Rejected: This font has no fvar table… | It's already static — nothing to freeze |
| WOFF/WOFF2 input | Decompressed to sfnt; output is uncompressed TTF | Convert output to WOFF2 for the web |
Custom axes (GRAD, FILL…) | Dropped with the rest; rendered at default | Use instancer if you need a non-default custom-axis position |
What freezing collapses, by axis type
Every axis is collapsed to its default master on freeze. This matters most for axes that reshape outlines or metrics.
| Axis | What you lose by freezing | Severity |
|---|---|---|
wght (weight) | All weights but the default | High if you needed a non-default weight |
wdth (width) | Condensed/expanded variants | High for condensed UI / display |
opsz (optical size) | Size-specific optical compensation + MVAR metrics | Medium–high for display vs text contrast |
slnt / ital | Slant/italic if on an axis (often it's a separate file) | Depends on family convention |
Custom (GRAD, YOPQ, XOPQ, FILL) | Parametric/grade/fill variation | App-specific; e.g. Material Symbols FILL |
Cookbook
Concrete cases with the panel output you'll see and the right response. These are the situations support questions actually come from.
Custom axis (Material Symbols FILL) — frozen at default
ExampleMaterial Symbols uses a FILL axis (0 = outline, 1 = filled). Freezing collapses to FILL's default. If you wanted filled icons, the freezer gives you outline icons.
Input: MaterialSymbols.var.ttf (FILL 0–1, default 0) Freeze: (any value typed) Result: Frozen at default: no — visual outlines still at default Output: outline icons (FILL=0), NOT filled For filled: fonttools varLib.instancer Material.ttf FILL=1 -o filled.ttf
Roboto Flex (multi-axis) — big size win, but only the default look
ExampleRoboto Flex has many axes (wght, wdth, opsz, GRAD, XOPQ, YOPQ, and more). Freezing strips a large gvar and saves a lot — but you get the default look across all of them.
Input: RobotoFlex.var.ttf (~13 axes) Freeze default: Removed tables: fvar, gvar, HVAR, MVAR, VVAR, cvar, STAT, avar Reduction: large (gvar was most of the file) Frozen at default: yes Result: a clean static at the default design point.
CJK variable font — strips tables, barely shrinks
ExampleA CJK variable face is mostly glyph outlines. Freezing removes the variation tables, but the file stays large because glyf/loca dominate.
Input: NotoSansCJK.var.otf (tens of thousands of glyphs) Freeze: Reduction: small (a few %) Lever that actually helps: subset to your characters first -> font-subsetter, then freeze the subset
Non-variable file dropped by mistake
ExampleSomeone drops an already-static TTF. The tool refuses rather than handing back an identical file.
Input: Helvetica.ttf (static, no fvar) Result: Error -> "This font has no fvar table — it isn't a variable font." Meaning: nothing to freeze; the file is already static.
OTF in, .ttf out — rename it
ExampleA CFF2 variable OTF freezes fine but the output extension lies about the flavor.
Input: SourceSerif.var.otf (OTTO / CFF2)
Output: SourceSerif.static.ttf (still OTTO inside)
Fix: mv SourceSerif.static.ttf SourceSerif.static.otf
@font-face: format("opentype")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.
Typed axis value isn't baked into outlines
By designThe single biggest misconception. opentype.js can't apply gvar deltas in-browser, so wght=700 (or any non-default value) is recorded in the Axes baked metric but the outlines stay at the default master. The panel says Frozen at default: no — visual outlines still at default. Use fonttools varLib.instancer for the actual instance.
Custom axes collapse to default
By designCustom axes — GRAD and parametric XOPQ/YOPQ in Roboto Flex, FILL in Material Symbols — are dropped with the rest of the variable machinery and render at their defaults. If your design depended on a non-default custom-axis position, the frozen file won't reflect it. fonttools is the tool that can pin custom axes with real outlines.
OTF output mislabeled as TTF
Expected — renameThe table-removal routine preserves the source sfnt flavor, so a CFF2 OTF (OTTO) comes out as a valid CFF font named .static.ttf with MIME font/ttf. It loads in engines that read the flavor; an extension-checking installer or strict validator may reject it. Rename to .otf and declare format("opentype").
head.checkSumAdjustment not recomputed
By design — sanitize if neededAfter rebuilding the table directory, the freeze leaves head.checkSumAdjustment (a whole-font checksum) unchanged. Browsers, FreeType, and DirectWrite ignore it. Strict validators (fontbakery, ots-sanitize) flag it. If your delivery path validates, run ots-sanitize or round-trip through fonttools ttx to recompute.
MVAR metrics revert to defaults
ExpectedMVAR shifts line height, x-height, and cap height per axis position. Once dropped, metrics revert to head/OS/2 defaults. Invisible at the default position; visible if you were relying on metric compensation at a non-default opsz/wght. Verify with the metrics analyzer and adjust CSS line-height if line-setting shifts.
avar non-linear mapping is gone
Expectedavar maps user-facing axis values to internal coordinates non-linearly (e.g. wght=700 → internal 0.85). It's removed on freeze. This is another reason browser-side non-default baking would be unreliable even in principle — and why the freezer sticks to the default master. fonttools handles avar correctly when instancing.
Output barely smaller than input (CJK)
Expected — glyph-dominatedSavings come from dropping gvar. CJK variable fonts are mostly glyf/loca across tens of thousands of glyphs, with a relatively small gvar. Freezing strips the tables but the glyph bulk remains, so the reduction is a few percent. To actually shrink CJK, subset to your characters with the font subsetter — then optionally freeze the subset.
Non-variable input rejected
Rejected — no fvarIf the file has no fvar table, the tool throws This font has no fvar table — it isn't a variable font. and produces nothing. A static TTF, an already-frozen file, or an icon font all land here. There's nothing to freeze — the file is already a single instance.
STAT removed → font menu shows a standalone style
By designSTAT describes the family's axes for the OS font picker. Dropping it means the frozen face appears as a standalone style, not as part of a variable family grid. Correct for a fallback; if you're building a desktop family of statics, set the name table style fields per file yourself — the freezer doesn't rewrite name.
Frozen WOFF2 source is a big TTF
Expected — no re-compressionWOFF2 input is decompressed to an sfnt and the output is uncompressed TTF, so a small variable WOFF2 can produce a much larger static TTF. The bytes are uncompressed, not bloated — run TTF→WOFF2 to get a web file that's usually smaller than the source variable WOFF2 (for a single weight).
Frequently asked questions
Why don't my typed axis values change the look of the static?
Because the freezer doesn't apply gvar deltas — opentype.js can't do that in the browser. Your values are recorded in the Axes baked metric and drive the Frozen at default flag, but the outlines come out at the default master. For real baked outlines at a chosen position, use fonttools varLib.instancer on the desktop.
What happens to custom axes like GRAD or FILL?
They're dropped along with the standard axes, and the static renders at their defaults (e.g. Material Symbols FILL defaults to 0 = outline). If you need a non-default custom-axis position with correct outlines, fonttools is the right tool — the JAD freezer always gives the default master.
Does freezing break OpenType features?
No. Only the eight variable tables (fvar, gvar, HVAR, MVAR, VVAR, cvar, STAT, avar) are removed. GSUB, GPOS, kern, cmap, and the rest are copied through untouched. Verify on the output with the OpenType features inspector.
Why is my output the same size as the input?
Almost certainly a glyph-dominated font (CJK or a huge icon set) where gvar is small relative to glyf/loca. Freezing removes the variation tables but the glyph bulk stays, so savings are a few percent. The real size lever for those fonts is subsetting, not freezing.
What about avar — does it matter?
avar remaps axis values non-linearly (e.g. wght=700 might map to internal 0.85). It's removed on freeze. That's part of why browser-side baking to a non-default position would be unreliable, and why the freezer stays at the default master. fonttools applies avar correctly when instancing.
Why does my OTF come out as a .ttf?
The freezer always names output <stem>.static.ttf with MIME font/ttf, but it preserves the original sfnt flavor. A CFF2 OTF stays OTTO-flavored inside. It loads in engines that read the flavor; rename to .otf (and use format("opentype")) if an extension-checking consumer complains.
Is the frozen font a strictly valid OpenType file?
It's valid enough for every shipping renderer, with one caveat: head.checkSumAdjustment isn't recomputed after the directory rewrite. Browsers ignore it; strict validators flag it. Run ots-sanitize or a fonttools ttx round-trip if you need a corrected checksum for a validating pipeline.
Will line-setting change after I freeze?
Only if the font used MVAR to adjust metrics off the default position. At the default position, metrics are identical. After freezing, metrics revert to head/OS/2 defaults — so a design that set opsz or wght off-default with metric compensation can wrap text slightly differently. Test it and adjust line-height if needed.
Can I freeze a font that isn't variable?
No — and you shouldn't want to. The tool checks for fvar and rejects anything without it (This font has no fvar table — it isn't a variable font.). A static font is already a single instance; there are no variable tables to strip.
When should I reach for fonttools instead of the JAD freezer?
Whenever you need real outlines at a non-default axis position (any specific weight/width/optical/custom value), or a partial instance that keeps some axes variable, or a corrected checksum in one step. fonttools varLib.instancer applies the gvar/avar machinery the browser tool can't. The freezer is the right call for a quick default-master static.
Does any of this differ between TTF, OTF, WOFF, and WOFF2 input?
The table-stripping is identical for all four; the difference is the front end. WOFF is zlib-decompressed, WOFF2 is brotli-decompressed (wawoff2), and TTF/OTF are used as-is. Output is always an uncompressed TTF carrying the source's original flavor. Behaviour and edge cases above apply across all inputs.
Where do I go for the build/CI version of this?
See the static-fallback build pipeline guide for the runner endpoint, the idempotent CI step, and the fonttools per-weight path. For the basic how-to, see freeze a variable font to a static TTF.
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.