expo-font-fallback
v0.3.1
Published
Fallback fonts for react native.
Downloads
2,313
Readme
expo-font-fallback
Custom, ordered font fallback chains for React Native text.
Define an app-wide fallback chain so your bundled fonts are tried in the order
you choose before the platform reaches its own system fallback. A regular
<Text style={{ fontFamily: 'Inter-Regular' }}> that contains CJK, symbol, or
other glyphs your base font lacks will render them from your bundled fallback
fonts instead of tofu boxes or an inconsistent system font.
<Text style={{ fontFamily: 'Inter-Regular' }}>Hello 你好 こんにちは 안녕하세요</Text>
// ^ Inter ^ rendered from your configured Noto fallbacks- iOS attaches a
UIFontDescriptorcascade list to the fonts React Native resolves for your<Text>. Works transparently for normal<Text>. - Android registers a
Typeface.CustomFallbackBuilderchain (API 29+) with React Native'sReactFontManager.
See How it works under the hood for the exact mechanism on each platform.
[!IMPORTANT] Expo Go is not supported. This library has native code and must run in a custom dev client or a release build. It targets the React Native new architecture (Fabric).
Installation
npx expo install expo-font-fallbackThen add the config plugin. Use whichever config format your project has.
The keys and values in chains are font file base names (the file name
without its extension). Run npx expo prebuild after changing the config.
{
"expo": {
"plugins": [
[
"expo-font-fallback",
{
"fonts": [
"./assets/fonts/Inter-Regular.ttf",
"./assets/fonts/Inter-Bold.ttf",
"./assets/fonts/NotoSansSC-Regular.otf"
],
"chains": {
"Inter-Regular": ["NotoSansSC-Regular"],
"Inter-Bold": ["NotoSansSC-Regular"]
},
"defaultFamily": "Inter-Regular"
}
]
]
}
}The same ["expo-font-fallback", { … }] array also works in a plain
app.config.js.
Import the plugin from the expo-font-fallback/plugin subpath. It is a typed
factory that returns the ["expo-font-fallback", props] tuple, so your fonts
and chains are type-checked:
import type { ExpoConfig } from 'expo/config';
import withFontFallback from 'expo-font-fallback/plugin';
const config: ExpoConfig = {
name: 'my-app',
slug: 'my-app',
plugins: [
withFontFallback({
fonts: [
'./assets/fonts/Inter-Regular.ttf',
'./assets/fonts/Inter-Bold.ttf',
'./assets/fonts/NotoSansSC-Regular.otf',
],
chains: {
'Inter-Regular': ['NotoSansSC-Regular'],
'Inter-Bold': ['NotoSansSC-Regular'],
},
// Apply to <Text> that sets no fontFamily (optional).
defaultFamily: 'Inter-Regular',
}),
],
};
export default config;The FontFallbackPluginConfig props type is also exported from the subpath if
you want to declare the config separately.
Usage
Call install() once, before your first React render — at module scope in
your entry file is ideal (this also lets the app-wide default take effect before
anything mounts):
import { FontFallback } from 'expo-font-fallback';
FontFallback.install({
warnOnMissingGlyphs: __DEV__,
});That's it. Existing <Text style={{ fontFamily: 'Inter-Regular' }}> now uses the
configured fallback chain — no other code changes.
App-wide default family
Set defaultFamily in the plugin config to apply a bundled family — and its
fallback chain — to every <Text> that specifies no fontFamily. A bare
<Text>Hello 你好</Text> then renders Hello in your default family and 你好
via that family's chain, instead of dropping to the OS system font. Bare
weighted text (e.g. fontWeight: 'bold') resolves to the matching face of the
default family.
defaultFamily must be one of fonts. You can override the embedded value at
runtime via install({ defaultFamily }). You don't change anything else — keep
writing plain <Text> from react-native; install() wires the default up
transparently (natively on iOS, and via a render-time Text shim on Android).
An explicit fontFamily on a <Text> always takes precedence.
Android note: the default-family cascade needs API 29+ (
Typeface.CustomFallbackBuilder). On older devices bare<Text>keeps the system font; explicit per-fontFamilychains still apply.
API
FontFallback.install(options?: {
warnOnMissingGlyphs?: boolean; // dev warnings for uncovered glyphs
logResolvedFonts?: boolean; // log resolved chains at install time
defaultFamily?: string; // override the embedded default family
}): boolean;
FontFallback.isInstalled(): boolean;
FontFallback.getConfig(): {
chains: Record<string, string[]>;
defaultFamily?: string;
};
// Development helper: which font covers each character, and what's missing.
FontFallback.checkText(text: string, baseFamily: string): GlyphCoverageReport;Font names
iOS looks fonts up by their internal PostScript name, which is often not
the same as the file name (e.g. NotoSansSC-Regular.otf has the PostScript name
NotoSansCJKsc-Regular). The config plugin reads each font's name table at
prebuild and maps names automatically, so you can keep using file base names.
If auto-detection picks the wrong name, override it explicitly:
{
fonts: ['./assets/fonts/NotoSansSC-Regular.otf'],
fontNames: {
'./assets/fonts/NotoSansSC-Regular.otf': {
ios: 'NotoSansCJKsc-Regular',
android: 'NotoSansSC-Regular',
},
},
// ...
}What this guarantees (and what it doesn't)
Guaranteed: in <Text>, for a configured base family — whether set
explicitly via fontFamily or applied through defaultFamily — the configured
bundled fallback chain is tried before the platform system fallback.
Not guaranteed: this does not globally disable system fallback. System UI,
alerts, third-party native components, TextInput (may resolve fonts
differently), WebView content, color emoji, and glyphs not covered by your
chain may still hit the platform's own fallback.
Platform limits
| Case | Behavior |
| --- | --- |
| iOS new arch (Fabric) | Fully supported — explicit fontFamily and the app-wide default. |
| Android API ≥ 29 | Full ordered custom fallback chain, including the default family. |
| Android API < 29 | Typeface.CustomFallbackBuilder is unavailable; the base font is registered without a chain, the default-family cascade is skipped, and a dev warning is logged. |
| Unrecognized RN version | If the iOS hook points are absent, fallback is inactive (logged); the app does not crash. |
Gotchas & limitations
A few non-obvious things worth knowing:
- Verifying with CJK is misleading. Both iOS and Android ship system fonts that cover CJK, Kana, Hangul, and most common scripts, so that text renders correctly even if the cascade does nothing. To actually confirm your chain is engaged, test a glyph no system font covers — e.g. a "last resort" font with boxed block-hint glyphs, or an exotic block like Egyptian Hieroglyphs. Seeing real CJK glyphs is not proof.
<Text>only. The hooks cover React Native<Text>.TextInput,WebViewcontent, native UI (alerts, navigation titles), and third-party components that render text natively resolve fonts on their own paths and are unaffected.getConfig().defaultFamilyreturns the resolved, platform-specific name (PostScript name on iOS, file base name on Android), which can differ from the string you wrote in config for fonts whose PostScript name doesn't match the file name. See Font names.checkTextis a config check, not a render oracle. It inspects the fonts directly and does not exercise RN's live resolution or OS system fallback, so it can disagree with what actually renders on screen.- iOS hooks a private RN method. The explicit-
fontFamilycascade swizzles-[RCTTextLayoutManager _nsAttributedStringFromAttributedString:]. If a future React Native renames or removes it, that path silently goes inactive (logged, no crash); the public default-font resolver is unaffected. - iOS weight matching is nearest-available. A bare bold default with only a Regular face registered resolves to the closest weight you bundled, not a synthesized bold.
- Android default relies on a JS
<Text>shim.install()wraps theTextexport fromreact-native. Components that render text without going through that export won't pick up the default family (explicit per-fontFamilychains still work everywhere). The shim is Android-only; iOS uses a native resolver. - Long chains are capped on Android.
Typeface.CustomFallbackBuilderallows up to 64 fallback families; entries beyond that are skipped with a warning. install()should run before your first render so the chains — and the Android default-font shim — are in place before anything mounts.
How it works under the hood
There are two halves: a build-time config plugin that bundles your fonts and embeds the chains, and a runtime that hooks React Native's text rendering on each platform so those chains actually take effect.
Build time (the config plugin)
During expo prebuild, the plugin:
- Copies your font files into the native projects (
ios/<App>/Fonts/andandroid/app/src/main/assets/fonts/) and registers them —UIAppFontsinInfo.pliston iOS, asset fonts on Android. - Resolves names per platform. iOS looks fonts up by their internal
PostScript name; Android by file base name. The plugin reads each
font's OpenType
nametable to map your file base names to the right lookup name for each platform (see Font names). - Emits
font_fallback_chains.jsoninto each app bundle — the same chains, keyed by PostScript names for iOS and by file base names for Android, plus the resolveddefaultFamily. The runtime loads this atinstall().
So your config keys stay as friendly file base names; the platform-specific name juggling happens at prebuild.
Runtime: iOS (new architecture / Fabric)
On Fabric, <Text> font resolution flows through the C++ function
RCTFontWithFontProperties. There are two cases, hooked separately:
- Text with an explicit
fontFamily. React Native resolves that font itself, so we post-process it: we swizzle the Objective-C method-[RCTTextLayoutManager _nsAttributedStringFromAttributedString:]— the single funnel every measure and draw pass goes through. After the original builds theNSAttributedString, we walk itsNSFontAttributeNameruns and, for any font whose family has a configured chain, swap in a copy carrying that chain as itsUIFontDescriptorcascade list. - Text with no
fontFamily(the app-wide default). This branch consults the publicRCTSetDefaultFontResolverhook. We install a resolver that returns yourdefaultFamily's face at the requested size/weight/italic, with its chain attached — so bare<Text>renders the default family and cascades through it.
Both hooks are best-effort: if a target symbol or method is missing on a future React Native version, installation is a no-op and the app does not crash. One important detail — each fallback entry is pinned to an empty cascade list so CoreText walks your chain in order instead of diverting to the system font as soon as one entry lacks a glyph.
An earlier approach used a dynamic-linker (
dyld) interpose ofRCTFontWithFontProperties. That does not work with the prebuilt React core: the symbol and its caller live in the same image, so the call is bound statically and never routes through the interpose. The resolver + swizzle above are the supported seams.
Runtime: Android (API 29+)
- Text with an explicit
fontFamily. We build aTypeface.CustomFallbackBuildertypeface (base font + your ordered fallback families) and register it under the family name withReactFontManager.addCustomFont. React Native checks that registry first when resolving afontFamily, so the cascade applies transparently. Weights are preserved because RN derives styled variants withTypeface.create, which keeps the custom font collection. - Text with no
fontFamily(the app-wide default). Android's attribute-less text path draws with the platform's native default font, and modern Android exposes no API to redirect it (Typeface.setDefaultno longer exists). So the default is applied at the JS layer instead:install()replaces theTextexport on thereact-nativemodule object with a thin wrapper that injectsdefaultFamilyas the base of the element's style (an explicitfontFamilystill wins). Because Metro compilesimport { Text }to live property reads, this is transparent and order-independent — you keep writing plain<Text>and change nothing. This shim is Android-only; iOS uses its native resolver.
checkText (dev helper)
checkText(text, baseFamily) walks [baseFamily, ...chain] and reports, per
character, which font first covers it and which codepoints nothing covers. It
inspects the fonts directly (it does not exercise the live render path), so it's
handy for spotting font-name mismatches and gaps in your chain during
development.
Contributing
License
MIT
