Self-hosted variable fonts, minus the glyphs you never use
Subsetting variable fonts down to the glyphs a site actually uses, and why unicode-range is not the same thing.
Tags:

Labrada font showcase from https://github.com/Omnibus-Type/Labrada
This website uses (at the time of writing this) two typeface families, a serif- and a mono-font, both variable fonts with a weight axis.
Labrada is the body face – a serif by Omnibus-Type. The copyright string inside the file says Copyright 2022 The Labrada Project Authors (https://github.com/Omnibus-Type/Labrada), version 1.000. Licensed under the SIL Open Font License 1.1.
Source Code Pro is the monospace face, by Adobe. Version 1.018 for the roman, 1.016 for the italic. Also SIL OFL 1.1, but with a reserved font name: the copyright reads © 2010 - 2020 Adobe Systems Incorporated, with Reserved Font Name 'Source'.
Both come in a roman and an italic file, so four files total:
- Labrada
- Labrada italic
- Source Code Pro
- Source Code Pro italic
A font file knows where it came from
A font carries its own provenance in its name table, so you do not need to remember where you downloaded it, or trust a filename.
Both commands in this post need uvx, which ships with uv. It runs a Python tool in a throwaway environment, so fonttools never has to be installed into the project or the system.
uvx --from "fonttools[woff,unicode]" python -c "
from fontTools.ttLib import TTFont
f = TTFont('fonts/labrada/Labrada-VariableFont_wght.woff2')
for r in f['name'].names:
if r.nameID in (0, 5, 9, 13, 14):
print(r.nameID, str(r)[:100])
"
Name ID 0 is the copyright, 5 the version, 9 the designer, 13 the license text and 14 the license URL. That is enough to identify any font file on disk.
Subsetting
A font file is a lookup table; codepoint in, glyph out. å is U+00E5, and the file maps that number to the drawing. Labrada shipped with 582 such entries.
Every glyph costs bytes, and in a variable font it costs extra: the file also stores the deltas describing how that glyph deforms across the whole weight axis, 100 to 900. That is the gvar table, and it is why a variable font is heavier than a static one at a single weight.
Subsetting deletes the glyphs you never use. It’s not compression, but removal.
The codepoint list below is Google Fonts’ own latin definition – the one they serve to a large part of the web. It looks arbitrary because it mostly is: one clean block, Latin-1, plus a scatter of single codepoints — ı, ⁴, − — that Google put in because sites across the web kept needing them, not because they belong to anything.
| Range | What it is |
|---|---|
U+0000-00FF |
ASCII and Latin-1. æ, ø and å live here |
U+0131 |
ı, Turkish dotless i |
U+0152-0153 |
Œ œ |
U+02BB-02BC |
ʻ ʼ, modifier apostrophes |
U+02C6 U+02DA U+02DC |
ˆ ˚ ˜, standalone accents |
U+0304 U+0308 U+0329 |
combining accents, drawn on top of the preceding character |
U+2000-206F |
General Punctuation: en dash, em dash, curly quotes, ellipsis, non-breaking space |
U+2074 U+20AC U+2122 |
⁴ € ™ |
U+2191 U+2193 |
↑ ↓ |
U+2212 U+2215 |
− true minus and ∕ division slash, not hyphen and solidus |
U+FEFF U+FFFD |
the invisible byte order mark, and � |
Check the range against the languages you publish in
The list is called latin, not “every language written in the latin script”, and those are not the same thing. It was assembled for the web at large, so the major Western European languages come through intact. Anything that reaches into Latin Extended does not, and that is most of Central Europe, the Baltics, Turkish, and several minority languages that share a country with a well-covered one.
Subsetting a font, step by step
pyftsubsetreads the original and builds a new file holding only the glyphs in the range.- Three flags decide what survives:
--layout-featuresfor kerning and ligatures,--name-IDsfor the license,--flavor=woff2for the output format. unicode-rangein the CSS declares which range the file covers.
Subsetting one file looks like this:
LATIN="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+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
uvx --from "fonttools[woff,unicode]" pyftsubset \
original.woff2 \
--output-file=subset.woff2 \
--flavor=woff2 --layout-features='*' --name-IDs='*' --unicodes="$LATIN"
Add whatever your own languages need to --unicodes on top of that list. Here the whole thing is wrapped in pnpm run subset-fonts, which loops it over all four files.
Notes on using uvx in this example
1. Two of the flags are not defaults:
--layout-features='*'keeps kerning and ligatures. Without it you get correct glyphs, badly spaced.--name-IDs='*'keeps the whole name table. This one is a licensing matter. By defaultpyftsubsetkeeps only name IDs 0–6 and silently drops the license text and license URL. The OFL asks that the copyright and license notice travel with the font, including in modified versions — and a subset is a modified version. Run the snippet above against the subset inpublic/to confirm 13 and 14 are still there.
2. Keep the originals. Subsetting is lossy and not reversible.
Unicode-range is a different thing
Subsetting changes the file. unicode-range changes nothing — it is a declaration in CSS about which characters a given @font-face covers, and it is worth not confusing the two.
What it buys you is a download the browser can skip: with the range declared, a page that uses no character inside it never fetches the file at all. That is how Google Fonts splits a family into latin, cyrillic and greek files and only sends what a page needs. With a single latin subset and a site that is entirely latin, the saving is close to nothing — I declare it because it documents what the file actually holds.
It is also not what keeps you from seeing tofu — the empty box □ a browser draws when nothing it can reach has a glyph for the character, named after what it looks like. Browsers fall back per character on their own: if Labrada has no glyph, the next family in the stack is tried, declared range or not. Tofu is what you get when that search runs out, not when a range is missing.
Keep the declaration and the file in step
Nothing enforces that unicode-range matches what you actually subset to, and a page renders either way. A mismatch just costs you something quietly, in one of two directions.
- Declaring more than the file holds: the browser fetches file for a character that is not in there, finds nothing, and falls through to the next family. A wasted download, but not worse.
- Declaring less than the file holds: the browser skips the face without looking. The glyph is sitting in the file, you paid the bytes to ship it, and it never gets drawn.
The second is the one to watch, because it is what happens when you widen the subset and forget the CSS.
The CSS around font files
All four faces are self-hosted and declared in one stylesheet. The parts of a block, in order:
@supports (font-variation-settings: normal)— wraps the lot, so none of it applies where variable fonts do not work@font-face— one block per file, so fourfont-family,font-style— the name to call it by, and which of the two files is the italicsrcwithurl()andformat()— where the file is and what it is. The same URL twice, with two spellings of the format, is an old fallback for browsers that disagreed on how to name variable-font supportfont-weight: 100 900— a range, not a value. This is what tells the browser the file carries a weight axis, and that any number between the two is fair gamefont-display: swap— paint in the fallback immediately and swap when the file lands, rather than holding the text invisible while it loadsunicode-range— which characters the file actually contains: the latin list plus the twelve Sámi codepoints, the same range the subsetting ran with. Declaring anything else is what puts the two out of step
The font-face code
@supports (font-variation-settings: normal) {
@font-face {
font-family: "Source Code Pro";
font-style: normal;
src:
url("/_font-source-code-pro/SourceCodePro-VariableFont_wght.woff2") format("woff2 supports variations"),
url("/_font-source-code-pro/SourceCodePro-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 200 900;
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+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+010C-010D, U+0110-0111, U+014A-014B, U+0160-0161, U+0166-0167, U+017D-017E;
}
@font-face {
font-family: "Source Code Pro";
font-style: italic;
src:
url("/_font-source-code-pro/SourceCodePro-Italic-VariableFont_wght.woff2") format("woff2 supports variations"),
url("/_font-source-code-pro/SourceCodePro-Italic-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 200 900;
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+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+010C-010D, U+0110-0111, U+014A-014B, U+0160-0161, U+0166-0167, U+017D-017E;
}
@font-face {
font-family: "Labrada";
font-style: normal;
src:
url("/_font-labrada/Labrada-VariableFont_wght.woff2") format("woff2 supports variations"),
url("/_font-labrada/Labrada-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 100 900;
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+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+010C-010D, U+0110-0111, U+014A-014B, U+0160-0161, U+0166-0167, U+017D-017E;
}
@font-face {
font-family: "Labrada";
font-style: italic;
src:
url("/_font-labrada/Labrada-Italic-VariableFont_wght.woff2") format("woff2 supports variations"),
url("/_font-labrada/Labrada-Italic-VariableFont_wght.woff2") format("woff2-variations");
font-weight: 100 900;
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+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+010C-010D, U+0110-0111, U+014A-014B, U+0160-0161, U+0166-0167, U+017D-017E;
}
}
@font-face is only as early as the stylesheet
A browser cannot discover a webfont until it has parsed the CSS that declares it, because @font-face lives there. With an external stylesheet that is three hops: the HTML, then the CSS, then the font file.
Inlining the CSS into the document removes the middle one. Here it took the longest critical path from 784ms to 405ms on throttled 4G — the fonts start downloading as soon as the HTML arrives, instead of waiting for a stylesheet to turn up and be parsed first.
In Astro that is build: { inlineStylesheets: "always" }. The trade is that every page carries its own copy of the CSS, so it is no longer cached across navigations.