Variable Fonts and Font Fallback in Flutter: Assets That Actually Render
FontVariation axes, FontFeature OpenType tags, and fontFamilyFallback chains — how Flutter resolves missing glyphs across bundled font assets and what to do about tofu.
Published on • November 13, 2026
AI Assistant

Declaring a font family in pubspec.yaml gets you the happy path: Latin text in your brand typeface, no surprises. Everything interesting happens at the edges — a weight your font file doesn’t contain, a Thai or CJK character it has no glyph for, an axis you didn’t know you could animate. This post covers the two mechanisms that decide what actually renders: variation axes and fallback chains. (Bundling and runtime FontLoader basics live in custom-fonts-flutter; web FOUT behavior lives in flutter-web-font-loading.)
The asset pipeline in one paragraph
Fonts declared under flutter: fonts: in pubspec.yaml are packaged as asset files, registered in the font manifest at build time, and handed to the engine by family name. The engine never downloads anything: if a glyph isn’t in your file, it falls back — through your declared fallback list, then through its own default font — and if nothing has the glyph, you get the dreaded tofu box. Every technique below is about controlling that resolution.
Variable fonts: one file, continuous axes
Per Flutter’s typography docs, a static font fixes weight, slant, and width at export time. A variable font stores those as continuous axes in a single file, and the FontVariation class — which conforms to the OpenType font variables spec — lets you pick any point on each axis:
Text(
'Shipping',
style: const TextStyle(
fontFamily: 'InterVar',
fontSize: 40,
fontVariations: [
FontVariation('wght', 760), // near-bold, not a static 700
FontVariation('wdth', 87.5), // condensed
],
),
)
Common registered axis tags are wght (weight), wdth (width), slnt (slant), and ital (italic). Two rules keep you out of trouble:
- The font must be designed for the axis. Requesting
wdthon a font that only shipswghtsilently does nothing. Google Fonts’ type tester shows which axes a family exposes before you commit to it. - Declare the variable file once in
pubspec.yaml. A variable font registered as a family can replace an entire folder of static weight files — but mixing a variable file and stale static files under the same family name is how you get inconsistent rendering between screens.
Because values are continuous, weight becomes animatable: tweening FontVariation('wght', …) between frames is how the “playful typography” effect on the Flutter blog is built — no sprite sheets, one font file.
FontFeature: boolean OpenType switches
Where FontVariation slides along an axis, FontFeature flips a boolean OpenType feature tag on a per-style basis:
const TextStyle(
features: [
FontFeature.tabularFigures(), // 'tnum' — columns that line up
FontFeature.smallCaps(), // 'smcp'
FontFeature.enable('zero'), // slashed zero, if the font has it
],
)
This matters for data-heavy screens: financial tables should request tabularFigures() so digits occupy equal width, while body copy keeps proportional figures. Again, the feature only exists if the font implemented the tag — Flutter will not synthesize it. Features compose with variations in one style, so a live-updating price ticker can pair FontFeature.tabularFigures() with an animated wght value and still keep its columns aligned while the weight shifts.
Fallback chains: the cure for tofu
TextStyle.fontFamilyFallback is an ordered list the engine walks when the primary family lacks a glyph:
final brandText = TextStyle(
fontFamily: 'InterVar',
fontFamilyFallback: const ['NotoSansThai', 'NotoSansCJK', 'sans-serif'],
);
Put it in your theme once rather than on each Text:
ThemeData(
textTheme: ThemeData(brightness: Brightness.light).textTheme.apply(
fontFamily: 'InterVar',
fontFamilyFallback: const ['NotoSansThai', 'NotoSans'],
),
)
Two practical notes from real shipping apps:
- Fallback is per-glyph, not per-string. A Thai sentence renders mostly from
NotoSansThaiwith Latin digits pulled from your brand font — that’s the desired behavior, but it means line heights and baselines come from a font you didn’t choose. Check mixed-script layouts at multiple text scales. - Only bundled or system fonts resolve offline. A fallback family that isn’t in
pubspec.yamland isn’t installed on the OS contributes nothing — on web, this is exactly where a missing family turns into browser-dependent substitution; see flutter-web-font-loading for how that plays out under CanvasKit.
A themed fallback chain lives on the ThemeData, so every descendant inherits it without repeating the list per Text widget:
ThemeData(
fontFamily: 'InterVar',
fontFamilyFallback: const ['NotoSansThai', 'NotoSansCJK'],
);
Order matters: Flutter walks the chain left to right per glyph, and the first family that contains the glyph wins — put the primary script first so Latin text never accidentally lands in the Thai font.
Verify before you ship
TextPainter gives you a cheap rendering check in widget tests: layout the string with your style, then assert the reported glyph coverage indirectly by measuring width against a control string of known-missing characters (tofu boxes have telltale metrics). At minimum, eyeball one screen per script you support — Latin, Thai, CJK — at 1.0x and 1.3x text scale, in both themes.
Takeaway: Declare one variable font file instead of a weight folder, animate FontVariation axes instead of shipping sprites, request FontFeature tags like tnum where alignment matters, and fix tofu with a themed fontFamilyFallback chain that only names fonts you actually bundle.
Sources
- https://docs.flutter.dev/ui/design/text/typography
- https://api.flutter.dev/flutter/dart-ui/FontVariation-class.html
- https://api.flutter.dev/flutter/dart-ui/FontFeature-class.html
- https://api.flutter.dev/flutter/painting/TextStyle-class.html
- https://learn.microsoft.com/en-us/typography/opentype/spec/otvaroverview