Subsetting Variable Fonts by Axis and Glyph
This guide is part of the Variable Font Loading Techniques topic, itself under the Font Loading & Delivery Strategies area.
A variable font ships every interpolatable instance of a typeface in a single binary: the full weight axis from 100 to 900, sometimes a width axis, sometimes an italic or slant axis, and a glyph set that covers Latin, Cyrillic, Greek, and often full CJK coverage. That is the entire point of the format — one file replaces four to eight static weights — but it also means the download can be larger than a small pair of static weights if you only ever render font-weight: 400 and font-weight: 600 on a Latin-only page. A raw variable font from a typical family lands between 120 KB and 340 KB before compression; a project that only needs Latin glyphs in the 400–700 weight range can cut that by 40–70% by subsetting the axis ranges and the glyph set together, without giving up the smooth interpolation the format exists to provide.
This is a distinct operation from the more common glyphhanger vs pyftsubset workflow used for static fonts. With a static font you only ever trim glyphs. With a variable font you have two independent knobs: which glyphs stay in the font, and which axis ranges stay interpolatable. Get the axis trim wrong and you either ship dead weight (the full 100–900 range when the design system only uses 400–700) or you accidentally collapse the font to a single static instance, defeating the reason you chose a variable font in the first place.
The two knobs are not interchangeable, and choosing wrong on either one produces a font that looks fine in a quick visual check but behaves incorrectly under load or in edge cases. Axis restriction is a size-vs-flexibility trade you make once at build time for the whole deployment; glyph subsetting is closer to the unicode-range work described in Unicode-Range & Font Subset Loading, except it happens inside the binary rather than via a CSS descriptor that lets the browser choose between multiple files. That distinction matters operationally: a unicode-range split lets the browser fetch only the subset a page actually needs at request time, while an axis-and-glyph-subsetted variable font is a single, fixed decision baked in before deployment — there is no per-request branching once the file is built.
Prerequisites
- Python 3.8+ with
fonttoolsinstalled:pip install fonttools brotli zopfli. - The source variable font as
.ttf(fonttools tooling operates on TrueType-flavored sources; if you only have a compiled.woff2, decompress it first withfonttools ttxor work from the original build output). - A confirmed list of the weights, widths, and scripts your CSS actually declares — check every
font-variation-settingsandfont-weightvalue shipped in your stylesheet, not just the design file. - Familiarity with Variable Font Axis Tuning if you also need to control
wght,wdth, orslntat render time — that page covers the CSS side; this page covers the binary-size side.
Implementation
The fonttools package ships two tools you need together: varLib.instancer to restrict or pin axes, and pyftsubset to restrict glyphs. Run instancer first, because narrowing the axis range changes which glyph outlines and deltas exist in the font, and subsetting after that operates on the already-trimmed variation data.
# Step 1 — restrict the wght axis to 400-700 (default docs are 400-700 body text,
# with a separate static bold-only face for headings), and drop wdth entirely
# by pinning it to its default (100 = normal width).
fonttools varLib.instancer \
--output Inter-Restricted.ttf \
Inter-Variable.ttf \
wght=400:700 \
wdth=100
# Step 2 — subset glyphs to Latin + Latin Extended, drop hinting instructions,
# and keep the variation tables (fvar, gvar, avar) that remain after Step 1.
pyftsubset Inter-Restricted.ttf \
--output-file=Inter-Restricted-Subset.ttf \
--unicodes="U+0020-007E,U+00A0-00FF,U+2018-201E" \
--layout-features='kern,liga,calt' \
--no-hinting \
--desubroutinize
# Step 3 — compile to WOFF2 for delivery.
fonttools ttLib.woff2 compress Inter-Restricted-Subset.ttf
Line by line: the wght=400:700 argument to instancer keeps the axis variable across that range but deletes every delta needed to render weights outside it — the font can no longer produce 100 or 900, and its fvar table advertises the new, narrower range so font-variation-settings: "wght" 850 silently clamps to 700 instead of failing. wdth=100 with a single value (no colon range) pins that axis to a static value and removes it from fvar entirely — this is the difference between "narrow the range" and "eliminate the axis," and mixing the two per-axis is exactly how you get a font that is both smaller and still variable where you need it. Step 2's --unicodes list is the same Unicode range syntax used for how unicode-range reduces font payload; --layout-features keeps only the OpenType features you actually reference (kerning, standard ligatures, contextual alternates) and drops the rest, which on a large family can remove tens of kilobytes of GSUB/GPOS data. --no-hinting --desubroutinize strips TrueType instructions and unpacks subroutine calls, both irrelevant at typical web font-sizes and both non-trivial contributors to file size in font families designed for print. Step 3 hands the result to the same WOFF2 compressor used everywhere else on the web — see how WOFF2 Brotli compression shrinks fonts for what that container does to the remaining bytes.
A defensive multi-script variant
Real projects rarely stay Latin-only forever, and instancer's axis clamp is a one-way, destructive operation — once you've restricted wght to 400:700, you cannot re-widen it from the output file, only from the original source. Keep the axis-restricted-but-glyph-full intermediate file around as your working master, and produce script-specific glyph subsets from it as separate build artifacts rather than re-running instancer per locale:
# Keep this as the canonical intermediate — axis-restricted, full glyph set.
fonttools varLib.instancer \
--output build/Inter-axis-restricted.ttf \
src/Inter-Variable.ttf \
wght=400:700
# Latin build — small, for the default bundle.
pyftsubset build/Inter-axis-restricted.ttf \
--output-file=dist/Inter-latin.ttf \
--unicodes="U+0020-007E,U+00A0-024F" \
--layout-features='kern,liga'
# Cyrillic build — served only to locales that need it, via unicode-range
# in a second @font-face block pointing at the same family name.
pyftsubset build/Inter-axis-restricted.ttf \
--output-file=dist/Inter-cyrillic.ttf \
--unicodes="U+0400-04FF,U+0500-052F" \
--layout-features='kern,liga'
Both output files declare the same restricted wght range and the same family name in CSS, letting the browser pick the correct glyph subset per character via unicode-range while both share identical axis behavior — a user who mixes Latin and Cyrillic text on one page gets consistent weight interpolation across both scripts because neither subset silently reintroduced the full 100–900 range. Axis-then-glyph subsetting also pairs with the CJK-and-emoji case covered in fallback stacks for CJK and emoji fonts, where CJK's own glyph count dwarfs the axis savings and a separate, unrestricted-axis CJK font is usually the better trade.
Choosing pin vs range per axis
Not every axis in a family deserves the same treatment, and the decision is per-axis, not per-font. A useful default is to range-restrict any axis your CSS actually varies at runtime and pin every axis your design system treats as a fixed constant:
| Axis | Site behavior | Recommended treatment | Typical size effect |
|---|---|---|---|
wght |
CSS transitions or multiple declared weights (400, 500, 700) | Range-restrict to the span used, e.g. 400:700 |
Moderate — deltas outside the range drop |
wdth |
Never referenced in CSS; family is always normal width | Pin to default, e.g. wdth=100 |
Large — whole axis and its deltas removed |
slnt |
One upright style only, no italic toggle | Pin to 0 |
Large — removes slant interpolation data |
opsz |
Tied to font-optical-sizing: auto across a range of sizes |
Range-restrict to the rendered size span | Moderate |
ital |
Separate italic weights served as their own file | Pin per file (0 for roman, 1 for italic build) |
Large per file, but two smaller files total |
Pinning removes the most bytes per axis because it deletes the entire dimension of variation data, while range-restricting keeps the axis alive but discards only the deltas outside the span you declared. A family with four registered axes but only one (wght) actually exercised by CSS often shrinks more from pinning the other three than from any amount of glyph trimming — profile both operations on your specific source file rather than assuming glyph subsetting is always the bigger win, since axis data density varies a great deal between type foundries and the number of masters they interpolated from.
Verification
Confirm the axis restriction actually took effect and the file is smaller before you ship it:
# Inspect the fvar table's axis ranges after instancer.
fonttools ttx -t fvar -o - Inter-Restricted.ttf | grep -A2 "AxisValueRecord\|MinValue\|MaxValue"
# Confirm byte size drop at each stage.
ls -la Inter-Variable.ttf Inter-Restricted.ttf Inter-Restricted-Subset.ttf
du -h *.woff2
In the browser, load the shipped WOFF2 and read back the live axis range from document.fonts:
document.fonts.ready.then(() => {
for (const face of document.fonts) {
console.log(face.family, face.variationSettings ?? "(static)");
}
});
There's no direct DOM API to read a FontFace's registered axis min/max, so the practical check is behavioral: set font-variation-settings: "wght" 900 on an element rendered in the restricted font and confirm in DevTools' Computed panel and a rendered screenshot that the glyph clamps to the 700 weight rather than rendering at 900. If it renders visually heavier than your declared maximum, the restriction did not take effect — usually because instancer ran against the wrong source file or a build cache served the pre-restriction binary.
Common Pitfalls
- Pinning an axis you still reference in CSS. If any stylesheet sets
font-variation-settings: "wdth" 87for a condensed heading style and you pinwdth=100during subsetting, that declaration silently does nothing — the font has no width axis left to respond to, and the heading renders at normal width with no console warning. Audit everyfont-variation-settingsvalue in your CSS before choosing which axes to pin versus range-restrict. - Running
pyftsubsetbeforevarLib.instancer. Subsetting first can strip glyph variation data that the instancer step still expects when narrowing ranges, producing a corrupted or inconsistentgvartable. Always instance the axes first, then subset glyphs from that output. - Assuming instancer output is reversible. Once an axis is instanced down to a range or a single point, the deleted interpolation deltas are gone from that file. Keep an unrestricted master source under version control; never overwrite it with instancer output.
- Forgetting
--layout-featuresand shipping unused OpenType features. A subsetted but feature-untrimmed variable font can still carry small-caps, stylistic sets, and swash alternates that add real weight to GSUB/GPOS tables for a feature the site's CSS never invokes viafont-feature-settings. - Testing only the default instance. Verifying that
font-weight: 400renders correctly after subsetting proves nothing about whether the retained range still interpolates smoothly at, say, 550 — always spot-check a mid-range, non-default weight both visually and via the Computed panel.
Frequently Asked Questions
Does axis restriction change the file's family name or require new @font-face declarations?
No — varLib.instancer preserves the font's name table by default, so the restricted file drops in under the same @font-face font-family value. You do not need to change any CSS selectors, only be aware that any font-variation-settings value outside the new range will clamp rather than error, which is worth a quick regression pass across your type scale.
Can I restrict axes without fully pinning any of them, keeping the font fully variable but just narrower?
Yes, and that's the common case: passing wght=400:700 keeps wght variable across that span while discarding data outside it, as opposed to wght=500 (no colon), which pins it to a single static value and removes the axis from fvar entirely. Choose range restriction when you animate or interpolate weight in the UI, and pinning only for axes (often wdth or slnt) that a given deployment never varies.
How much size reduction should I actually expect? It depends heavily on the source family, but a Latin-only site trimming a full 100–900, multi-width variable font down to a 400–700, single-width, Latin+Latin-Extended subset commonly sees 40–65% reduction in the uncompressed TTF, and a further reduction after WOFF2/Brotli recompresses the smaller glyph and variation tables — measure your own family rather than assuming these figures transfer directly, since glyph count and the density of interpolation data vary widely between foundries.
Will axis-restricting and glyph-subsetting break variable font animations like the one described in animating font weight?
Not if the restricted range still covers the animated span. An animation that transitions font-variation-settings: "wght" between 400 and 600 keeps working after restricting the axis to 400:700, because every value the animation visits is still present in the retained delta set; the animation would only break, by clamping to the nearest retained endpoint, if it tried to reach a value like 850 that fell outside the new range.
Should I subset a variable font differently for a design system versus a marketing microsite?
Generally yes. A design system that serves dozens of pages across a company benefits from a wider retained wght range and full script coverage, since the one-time size cost amortizes across every page that shares the cached file, and see Variable Fonts vs Static Weights for when a static-weight fallback beats a variable font entirely. A single-purpose microsite with two fixed weights and one script is often better served by an aggressively pinned, narrowly ranged build, or even a static subset if the variable axis is not used at all.