Typography Scale & Font Loading
expo-font, custom families, and accessible text sizing.
Search across all documentation pages
expo-font, custom families, and accessible text sizing.
Quick-reference recipe card - copy-paste ready.
npx expo install expo-font expo-splash-screen// app/_layout.tsx (Expo Router root) or App.tsx
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { Text, View } from "react-native";
import { typography } from "./theme/typography";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
"Inter-Regular": require("./assets/fonts/Inter-Regular.ttf"),
"Inter-SemiBold": require("./assets/fonts/Inter-SemiBold.ttf"),
});
useEffect(() => {
if (loaded || error) SplashScreen.hideAsync();
}, [loaded, error]);
if (!loaded && !error) return null;
return (
<View style={{ flex: 1 }}>
<Text style={typography.title}>Typography ready</Text>
<Text style={typography.body}>Inter loads before first paint.</Text>
</View>
);
}// theme/typography.ts
import { StyleSheet } from "react-native";
export const typography = StyleSheet.create({
title: {
fontFamily: "Inter-SemiBold",
fontSize: 24,
lineHeight: 32,
letterSpacing: -0.3,
color: "#0f172a",
},
body: {
fontFamily: "Inter-Regular",
fontSize: 16,
lineHeight: 24,
color: "#334155",
},
caption: {
fontFamily: "Inter-Regular",
fontSize: 12,
lineHeight: 16,
color: "#64748b",
},
});When to reach for this: Brand fonts, consistent heading hierarchy, or accessibility-safe text - you need custom families loaded before UI renders and a token scale instead of magic numbers scattered in screens.
import { useFonts } from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { PixelRatio, StyleSheet, Text, View } from "react-native";
SplashScreen.preventAutoHideAsync();
const FONT_SCALE = PixelRatio.getFontScale();
type TextVariant = "display" | "title" | "body" | "caption" | "label";
const variantStyles: Record<TextVariant, object> = {
display: typography.display,
title: typography.title,
body: typography.body,
caption: typography.caption,
label: typography.label,
};
function AppText({
variant = "body",
children,
maxScale = 1.35,
}: {
variant?: TextVariant;
children: React.ReactNode;
maxScale?: number;
}) {
return (
<Text
style={variantStyles[variant]}
allowFontScaling
maxFontSizeMultiplier={maxScale}
>
{children}
</Text>
);
}
export default function TypographyScreen() {
const [fontsLoaded, fontError] = useFonts({
"Inter-Regular": require("./assets/fonts/Inter-Regular.ttf"),
"Inter-SemiBold": require("./assets/fonts/Inter-SemiBold.ttf"),
"Inter-Bold": require("./assets/fonts/Inter-Bold.ttf"),
});
useEffect(() => {
if (fontsLoaded || fontError) SplashScreen.hideAsync();
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return null;
return (
<View style={styles.screen}>
<AppText variant="display">Good morning</AppText>
<AppText variant="title">Your tasks today</AppText>
<AppText variant="body">
System font scale is {FONT_SCALE.toFixed(2)}×. Body copy scales with accessibility
settings up to the configured cap.
</AppText>
<AppText variant="caption">Last synced 2 min ago</AppText>
<View style={styles.chip}>
<AppText variant="label" maxScale={1.2}>
3 overdue
</AppText>
</View>
</View>
);
}
const typography = StyleSheet.create({
display: {
fontFamily: "Inter-Bold",
fontSize: 34,
lineHeight: 40,
letterSpacing: -0.5,
color: "#0f172a",
},
title: {
fontFamily: "Inter-SemiBold",
fontSize: 22,
lineHeight: 28,
color: "#0f172a",
},
body: {
fontFamily: "Inter-Regular",
fontSize: 16,
lineHeight: 24,
color: "#334155",
},
caption: {
fontFamily: "Inter-Regular",
fontSize: 13,
lineHeight: 18,
color: "#64748b",
},
label: {
fontFamily: "Inter-SemiBold",
fontSize: 12,
lineHeight: 16,
letterSpacing: 0.6,
textTransform: "uppercase",
color: "#475569",
},
});
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 12, backgroundColor: "#f8fafc" },
chip: {
alignSelf: "flex-start",
marginTop: 8,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
backgroundColor: "#fee2e2",
},
});What this demonstrates:
useFonts loads multiple weights as separate family keys - the Expo SDK 57 pattern for static TTF filesdisplay, title, body, caption, label) centralize size, line height, and familyAppText wrapper applies allowFontScaling and per-variant maxFontSizeMultiplier capsPixelRatio.getFontScale() surfaces the current accessibility multiplier for layout debuggingexpo-font ships with Expo SDK 57 and wraps native font registration on iOS and Android.
| API | Use case |
|---|---|
useFonts(map) | React hook - returns [loaded, error]; best for root layout gating |
Font.loadAsync(map) | Imperative preload in bootstrap routines alongside Asset.loadAsync |
Font.isLoaded(name) | Check a single family before rendering a lazy subtree |
import * as Font from "expo-font";
// Imperative batch (e.g. before navigation mounts)
await Font.loadAsync({
"Inter-Regular": require("./assets/fonts/Inter-Regular.ttf"),
});Install aligned to SDK 57:
npx expo install expo-font expo-splash-screenFont files must live under assets/ and be statically require()'d - Metro bundles them at build time.
Without a gate, React renders immediately with the system font, then swaps when custom fonts arrive (flash of unstyled text).
import * as SplashScreen from "expo-splash-screen";
SplashScreen.preventAutoHideAsync();
// In root component
useEffect(() => {
if (loaded || error) SplashScreen.hideAsync();
}, [loaded, error]);
if (!loaded && !error) return null;preventAutoHideAsync at module scope before the first render.null (or a minimal placeholder) while loaded is false.React Native does not map fontWeight: "600" to a custom TTF automatically. Each weight/style is usually a separate file registered under its own family name.
// Works - explicit family per file
{ fontFamily: "Inter-SemiBold", fontSize: 18 }
// Unreliable with custom fonts - may fall back to system bold
{ fontFamily: "Inter-Regular", fontWeight: "600", fontSize: 18 }| Approach | Pros | Cons |
|---|---|---|
Separate files per weight (Inter-Bold, etc.) | Predictable on iOS and Android | More files to load |
| Single variable font (when supported) | One file | Verify RN/Expo support for your font format |
System font (fontFamily: undefined) | Zero load time, native feel | No brand typography |
Define a fixed ramp - do not pick fontSize per screen ad hoc.
| Token | Typical fontSize / lineHeight | Use |
|---|---|---|
display | 32–40 / 38–48 | Hero headings |
title | 20–24 / 26–32 | Screen titles, card headers |
body | 15–17 / 22–26 | Paragraphs, list primary text |
caption | 11–13 / 14–18 | Timestamps, metadata |
label | 11–12 / 14–16 | Chips, badges, overlines |
export const fontSize = {
xs: 12,
sm: 14,
md: 16,
lg: 20,
xl: 24,
"2xl": 32,
} as const;
// lineHeight ≈ 1.4–1.5 × fontSize for body; tighter for displayPair tokens with letterSpacing and textTransform on labels - keep numeric rhythm in one module.
iOS and Android expose font scale accessibility settings. React Native respects them by default on Text.
| Prop | Default | Purpose |
|---|---|---|
allowFontScaling | true | When false, ignores system font scale |
maxFontSizeMultiplier | unlimited | Caps how large text can grow (iOS/Android) |
adjustsFontSizeToFit | false | Shrinks text to fit numberOfLines box (iOS) |
<Text allowFontScaling maxFontSizeMultiplier={1.3} style={typography.body}>
Accessible body copy
</Text>
// Dense UI - toolbar, tab labels
<Text maxFontSizeMultiplier={1.1} numberOfLines={1}>
Dashboard
</Text>Use PixelRatio.getFontScale() to log or adapt layouts at extreme scales (e.g. switch list rows to two lines).
Do not disable allowFontScaling app-wide - it breaks accessibility. Cap selectively on chips, nav bars, and fixed-height buttons.
lineHeight is in density-independent pixels, not unitless like CSS line-height: 1.5.lineHeight = fontSize × 1.4 for body, tighter for large display type.Text inside a row with alignItems: "center" may look off if line heights differ - normalize variants.body: {
fontSize: 16,
lineHeight: 24, // 1.5× - comfortable paragraph
},
display: {
fontSize: 34,
lineHeight: 40, // ~1.18× - tight hero
},Load fonts in the same bootstrap routine as image preloading:
import { Asset } from "expo-asset";
import * as Font from "expo-font";
export async function loadResources() {
await Promise.all([
Asset.loadAsync([require("./assets/logo.png")]),
Font.loadAsync({
"Inter-Regular": require("./assets/fonts/Inter-Regular.ttf"),
}),
]);
}See the images and assets article for splash coordination with Asset.loadAsync.
import type { TextStyle } from "react-native";
export type TypographyVariant = "display" | "title" | "body" | "caption";
export const typography: Record<TypographyVariant, TextStyle> = StyleSheet.create({
display: { fontFamily: "Inter-Bold", fontSize: 34, lineHeight: 40 },
title: { fontFamily: "Inter-SemiBold", fontSize: 22, lineHeight: 28 },
body: { fontFamily: "Inter-Regular", fontSize: 16, lineHeight: 24 },
caption: { fontFamily: "Inter-Regular", fontSize: 13, lineHeight: 18 },
});TextStyle includes fontFamily, fontSize, lineHeight, letterSpacing, fontVariant.useFonts are strings - use the same string in fontFamily.require() of .ttf / .otf returns a number (asset module ID) at build time.Flash of system font - Rendering text before useFonts resolves. Fix: Gate root layout with splash screen + return null until loaded.
fontWeight with custom family - fontFamily: "Inter" + fontWeight: "700" often ignores the custom file. Fix: Register each weight as its own family name and reference it directly.
Wrong font family string - fontFamily must match the useFonts key exactly ("Inter-SemiBold", not "Inter SemiBold"). Fix: Copy keys from the load map.
Missing font file in bundle - Dynamic require paths fail Metro static analysis. Fix: Static require("./assets/fonts/Inter-Regular.ttf") only.
Uncapped scaling breaks layouts - Large accessibility fonts overflow toolbars and chips. Fix: maxFontSizeMultiplier on dense components; allow full scaling on body copy.
lineHeight too small - Descenders clip on Android with tight line heights. Fix: Increase lineHeight or add paddingVertical on single-line badges.
Splash never hides on font error - Waiting only for loaded. Fix: if (loaded || error) SplashScreen.hideAsync() and show a fallback system-font UI.
Variable font assumptions - Not all variable font files work identically across platforms. Fix: Test on both iOS and Android devices; fall back to static weights if weights do not resolve.
| Alternative | Use When | Don't Use When |
|---|---|---|
useFonts hook at root | Expo Router / single root layout | Fonts needed only in one lazy screen (still preload at root for consistency) |
Font.loadAsync in bootstrap | Shared loader with Asset.loadAsync | You prefer hook-driven splash gating |
| System fonts only | Utilities, internal tools, fastest ship | Brand guidelines require custom type |
@expo-google-fonts/* packages | Popular open fonts without bundling files | Corporate licensed fonts not on Google Fonts |
expo-google-fonts + useFonts | Quick Inter/Roboto setup | You already ship licensed OTF/TTF assets |
| Tamagui / NativeWind text tokens | Design system with themed typography | One screen with two text styles |
npx expo install expo-font expo-splash-screenexpo-font is included in the Expo SDK - expo install pins a compatible version.useFonts from expo-font in your root layout..ttf or .otf files under assets/fonts/ and static require() them.useFonts is a React hook that triggers re-render when loading completes - ideal for component trees.Font.loadAsync returns a Promise - ideal for imperative bootstrap before registerRootComponent.require() asset.useFonts."Inter-Bold" in fontFamily, not "Inter" with fontWeight: "700".SplashScreen.preventAutoHideAsync();
// ...
if (!loaded && !error) return null;Text until loaded is true.fontSize and lineHeight.theme/typography.ts as StyleSheet.create or plain objects.fontSize: 17 literals.true for almost all user-facing copy - respects accessibility settings.false only for decorative text or fixed-size icons implemented as text (rare).maxFontSizeMultiplier over disabling scaling entirely.1.0 means no scaling; 1.3 allows up to 30% larger than the style's fontSize.1.5 or higher.fontFamily string.require() path.fontWeight instead of the correct family file - load and reference each weight explicitly.lineHeight: 24 with fontSize: 16.fontSize; display headings: ~1.1–1.2×.lineHeight or add vertical padding.@expo-google-fonts/inter and similar packages wrap useFonts with pre-bundled assets.npx expo install @expo-google-fonts/inter expo-font.require() of OTF/TTF files.numberOfLines, flexShrink: 1, and multi-line toolbars at high scales.PixelRatio.getFontScale() helps debug layout at accessibility settings.numberOfLines is set - mostly iOS.const typography = StyleSheet.create({ ... }) satisfies Record<string, TextStyle>;type Variant = keyof typeof typography.variant: Variant for autocomplete.Text inherits parent fontSize, fontFamily, color, and lineHeight unless overridden.fontFamily if the parent body style already sets it.StyleSheet.create and text style compositionuseFonts alongside Asset.loadAsyncText nesting and truncationStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026