Skip to content
Blog

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 wdth on a font that only ships wght silently 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:

  1. Fallback is per-glyph, not per-string. A Thai sentence renders mostly from NotoSansThai with 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.
  2. Only bundled or system fonts resolve offline. A fallback family that isn’t in pubspec.yaml and 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