Your custom font has a fallback, and you didn't choose it

A custom font covers a subset of Unicode. Everything outside it falls back, and the fallback decides how your app renders emoji, non-Latin scripts and symbols — usually without anyone checking.

6 min read
androidcomposekotlintext

Day 53 — Your custom font has a fallback, and you didn't choose it

Day 53 of 100. Fonts look like a configuration task until an app ships in a market whose script the brand font doesn't cover.

The symptom

A beautifully branded app that renders Hindi as boxes:

val Brand = FontFamily(
    Font(R.font.brand_regular, FontWeight.Normal),
    Font(R.font.brand_bold, FontWeight.Bold),
)

val AppTypography = Typography(
    bodyLarge = TextStyle(fontFamily = Brand, fontSize = 16.sp),
    // …
)

English is perfect. Hindi, Thai and Arabic show tofu — the empty rectangle a font renders for a glyph it doesn't have. Emoji render in a flat monochrome outline instead of colour.

Nothing errored. The font simply doesn't contain those glyphs, and something had to be drawn.

Why the obvious fix fails

The obvious fix is to ship more fonts:

val Brand = FontFamily(
    Font(R.font.brand_regular, FontWeight.Normal),
    Font(R.font.brand_devanagari, FontWeight.Normal),   // doesn't work like this
)

A FontFamily selects between its entries by weight and style, not by script. Two entries at the same weight is ambiguous, and the first match wins — so this either changes nothing or breaks Latin rendering.

Shipping full multi-script coverage in the APK isn't viable either: a font covering Devanagari, Thai, Arabic, Cyrillic and CJK is tens of megabytes.

A FontFamily selects by weight; unsupported glyphs fall through to the system chain

The actual mechanism

Text rendering walks a fallback chain. Your FontFamily is consulted first; any codepoint it lacks falls through to the system fonts, which cover the scripts the device is configured for.

So the Hindi text isn't unstyled — it's rendered by the system's Devanagari font, which usually looks fine. Tofu appears only when no font in the chain has the glyph, which on a modern device is rare and usually means a symbol or a very new emoji.

Three consequences worth designing around:

Your brand font applies to the scripts it covers, and no others. That's acceptable, and it's better to know than to discover. Check your target locales early.

Line metrics can differ between fallbacks, so a paragraph mixing scripts may have uneven line heights. Setting an explicit lineHeight in the style — which the type scale already does, Day 46 — makes it consistent.

Emoji are their own case, below.

Downloadable fonts

Rather than bundling files, fetch from Google Fonts at runtime:

val provider = GoogleFont.Provider(
    providerAuthority = "com.google.android.gms.fonts",
    providerPackage = "com.google.android.gms",
    certificates = R.array.com_google_android_gms_fonts_certs,
)

val Brand = FontFamily(
    Font(GoogleFont("Inter"), provider, FontWeight.Normal),
    Font(GoogleFont("Inter"), provider, FontWeight.Bold),
)

The trade: no APK weight, and the font may not be present on first render. Compose falls back synchronously while the download happens, so the first frame can show a different face and then swap — the "flash of unstyled text" the web has dealt with for years.

Two mitigations. Provide a bundled fallback whose metrics are close, so the swap doesn't reflow:

val Brand = FontFamily(
    Font(GoogleFont("Inter"), provider, FontWeight.Normal),
    Font(R.font.inter_fallback, FontWeight.Normal),      // bundled, same metrics
)

And prefetch at startup so the common case is already resolved by the time UI renders.

For a font central to the brand, bundling remains the safer choice. Downloadable fonts suit secondary faces and apps where APK size is the binding constraint.

Variable fonts

One file, a continuous weight axis:

val Brand = FontFamily(
    Font(
        R.font.brand_variable,
        weight = FontWeight.Normal,
        variationSettings = FontVariation.Settings(FontVariation.weight(400)),
    ),
    Font(
        R.font.brand_variable,
        weight = FontWeight.Medium,
        variationSettings = FontVariation.Settings(FontVariation.weight(500)),
    ),
)

Same file referenced twice with different axis values. For a brand shipping six weights this is a substantial size saving, and it makes intermediate weights available without another file.

Other axes — optical size, width, slant — work the same way where the font supports them. FontVariation.opticalSizing() is the interesting one for a type scale, since it lets display sizes use a face tuned for large rendering.

Font loading is not free

A detail that shows up as a slow first frame rather than as a font problem. Resolving a font family is I/O, and Compose does it lazily on first use — so the first screen that renders your brand font pays for parsing the file.

FontFamily.Resolver can be warmed ahead of time:

val resolver = LocalFontFamilyResolver.current
LaunchedEffect(Unit) {
    resolver.preload(Brand)
}

Doing this in a splash or a startup composable moves the cost somewhere the user is already waiting. It's a small win on a fast device and a visible one on a low-end phone, where font parsing can be tens of milliseconds per face — multiplied by however many weights the type scale references.

Emoji

Emoji are colour glyphs, and support depends on the device's system font — an older device simply lacks recent emoji and renders tofu.

androidx.emoji2 backfills this, and it's automatic for Text in a modern Compose setup:

implementation("androidx.emoji2:emoji2:1.5.0")

The library ships glyph data and renders unsupported emoji itself, so a 2024 emoji appears on a 2019 device. It's one dependency, and without it emoji-heavy user content looks broken on exactly the devices least likely to be in your test lab.

Two related details:

Emoji don't take your font's weight. Bolding a string containing emoji bolds the text and leaves the emoji unchanged, because they come from a different font. That's correct and occasionally surprising in a design review. The same applies to colour: tinting text does nothing to the emoji in it.

Emoji are multi-codepoint. A family emoji or a skin-tone variant is several codepoints joined by zero-width joiners, so naive string operations split them. state.text.length counts codepoints, not perceived characters — which matters for a character-count limit next to an input field. Use BreakIterator for grapheme clusters if the count is user-facing.

How to prove it

The coverage question is answerable before you ship:

@Preview(locale = "hi") @Preview(locale = "ar") @Preview(locale = "th")
@Composable fun TypeAcrossScripts() = AppTheme { ArticleCard(translatedSample) }

Any script rendering in a visibly different face is falling back — usually fine, and worth having seen. Boxes mean no font in the chain covers it.

For emoji, a string of recent ones plus an old test device is the direct check:

@Preview @Composable fun EmojiCoverage() = Text("🫠🩵🫶🏽👨‍👩‍👧‍👦")

The last of those is a four-person family with joiners — if it renders as four separate people, grapheme handling is off somewhere.

What this generalizes to

The principle is there is always a fallback, and not choosing it is still a choice. Fonts, locales, image formats, network conditions — systems degrade rather than fail, and the degraded path is the one nobody looks at because nothing errors.

The habit worth forming is asking "what happens when this isn't available" at the point you add the dependency, rather than when a user in a market you didn't test files a bug about boxes.

Fonts make the question unusually concrete, because the answer is visible in a screenshot and the cost of asking is one preview annotation. Most fallback paths aren't that courteous.

Tomorrow, Day 54 closes the text pillar with autofill — the API that decides whether your login screen is a five-second task or a thirty-second one.


Day 53 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Fonts in Compose.