Complete guide to implementing custom fonts in React Native and Expo. Covers expo-font setup, font loading patterns, cross-platform consistency, and performance optimization.
Complete technical guide for implementing custom typography in React Native and Expo applications.
npx expo install expo-font
apps/mobile/
├── assets/
│ └── fonts/
│ ├── Inter-Regular.ttf
│ ├── Inter-Medium.ttf
│ ├── Inter-Bold.ttf
│ └── ... other fonts
├── app/
│ └── _layout.tsx # Font loading here
└── package.json
// apps/mobile/app/_layout.tsx
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import { Stack } from 'expo-router';
// Prevent splash screen from auto-hiding
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('../assets/fonts/Inter-Medium.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
});
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
// Handle font loading error
if (fontError) {
console.error('Font loading error:', fontError);
// Continue with system fonts
}
if (!fontsLoaded && !fontError) {
return null;
}
return <Stack />;
}
// Load all fonts at app start
const [fontsLoaded] = useFonts({
// Primary font family
'Inter-Light': require('../assets/fonts/Inter-Light.ttf'),
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Medium': require('../assets/fonts/Inter-Medium.ttf'),
'Inter-SemiBold': require('../assets/fonts/Inter-SemiBold.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
// Secondary font family
'Nunito-Regular': require('../assets/fonts/Nunito-Regular.ttf'),
'Nunito-Bold': require('../assets/fonts/Nunito-Bold.ttf'),
});
// packages/ui/src/hooks/useLazyFont.ts
import { useFonts } from 'expo-font';
import { useEffect, useState } from 'react';
type FontFamily = 'cjk' | 'arabic' | 'devanagari';
const FONT_SOURCES: Record<FontFamily, Record<string, any>> = {
cjk: {
'NotoSansCJK-Regular': require('../../assets/fonts/NotoSansCJK-Regular.otf'),
},
arabic: {
'NotoArabic-Regular': require('../../assets/fonts/NotoNaskhArabic-Regular.ttf'),
},
devanagari: {
'NotoDevanagari-Regular': require('../../assets/fonts/NotoSansDevanagari-Regular.ttf'),
},
};
export function useLazyFont(family: FontFamily | null) {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
if (family) {
setShouldLoad(true);
}
}, [family]);
const [fontsLoaded] = useFonts(
shouldLoad && family ? FONT_SOURCES[family] : {}
);
return {
fontsLoaded: !family || fontsLoaded,
fontFamily: family ? Object.keys(FONT_SOURCES[family])[0] : 'System',
};
}
// packages/ui/src/utils/fontLoader.ts
import * as Font from 'expo-font';
const loadedFonts = new Set<string>();
export async function loadFontDynamic(
fontName: string,
fontSource: any
): Promise<boolean> {
if (loadedFonts.has(fontName)) {
return true;
}
try {
await Font.loadAsync({ [fontName]: fontSource });
loadedFonts.add(fontName);
return true;
} catch (error) {
console.error(`Failed to load font ${fontName}:`, error);
return false;
}
}
export function isFontLoaded(fontName: string): boolean {
return loadedFonts.has(fontName);
}
# Install specific font packages
npx expo install @expo-google-fonts/inter
npx expo install @expo-google-fonts/nunito
npx expo install @expo-google-fonts/poppins
# Or search for fonts
npx expo search google-fonts
// apps/mobile/app/_layout.tsx
import { useFonts } from 'expo-font';
import {
Inter_100Thin,
Inter_200ExtraLight,
Inter_300Light,
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
Inter_800ExtraBold,
Inter_900Black,
} from '@expo-google-fonts/inter';
import {
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
} from '@expo-google-fonts/nunito';
export default function RootLayout() {
const [fontsLoaded] = useFonts({
// Remap to consistent naming
'Inter-Thin': Inter_100Thin,
'Inter-ExtraLight': Inter_200ExtraLight,
'Inter-Light': Inter_300Light,
'Inter-Regular': Inter_400Regular,
'Inter-Medium': Inter_500Medium,
'Inter-SemiBold': Inter_600SemiBold,
'Inter-Bold': Inter_700Bold,
'Inter-ExtraBold': Inter_800ExtraBold,
'Inter-Black': Inter_900Black,
'Nunito-Regular': Nunito_400Regular,
'Nunito-SemiBold': Nunito_600SemiBold,
'Nunito-Bold': Nunito_700Bold,
});
// ...
}
| Format | iOS | Android | Recommended |
|---|---|---|---|
.ttf | Yes | Yes | Yes |
.otf | Yes | Yes | Yes |
.woff | No | No | No |
.woff2 | No | No | No |
assets/fonts/ directoryuseFonts hookconst [fontsLoaded] = useFonts({
'CustomFont-Regular': require('../assets/fonts/CustomFont-Regular.ttf'),
'CustomFont-Bold': require('../assets/fonts/CustomFont-Bold.ttf'),
});
// Recommended naming pattern: FamilyName-Weight
const fontNames = {
'Satoshi-Regular': '...',
'Satoshi-Medium': '...',
'Satoshi-Bold': '...',
'Satoshi-Black': '...',
};
// Avoid: Inconsistent naming
const badNames = {
'satoshi': '...', // Missing weight
'SatoshiBold': '...', // Missing hyphen
'satoshi_bold': '...', // Underscores
};
// packages/ui/src/tokens/fontWeights.ts
import { Platform, TextStyle } from 'react-native';
// React Native weight values differ between platforms
export const fontWeights: Record<string, TextStyle['fontWeight']> = {
thin: '100',
extraLight: '200',
light: '300',
regular: '400',
medium: '500',
semiBold: '600',
bold: '700',
extraBold: '800',
black: '900',
};
// For custom fonts, use fontFamily instead of fontWeight
export function getFontStyle(
family: string,
weight: keyof typeof fontWeights
): TextStyle {
// Custom fonts should have weight baked into family name
return {
fontFamily: `${family}-${capitalizeWeight(weight)}`,
};
}
function capitalizeWeight(weight: string): string {
return weight.charAt(0).toUpperCase() + weight.slice(1);
}
// packages/ui/src/tokens/platformFonts.ts
import { Platform } from 'react-native';
export const platformFonts = {
// System fonts
system: Platform.select({
ios: 'System',
android: 'Roboto',
default: 'System',
}),
// Monospace
mono: Platform.select({
ios: 'Menlo',
android: 'monospace',
default: 'monospace',
}),
// Serif
serif: Platform.select({
ios: 'Georgia',
android: 'serif',
default: 'serif',
}),
};
// Use custom fonts for consistency
export const appFonts = {
primary: 'Inter-Regular',
primaryMedium: 'Inter-Medium',
primaryBold: 'Inter-Bold',
secondary: 'Nunito-Regular',
secondaryBold: 'Nunito-Bold',
mono: 'JetBrainsMono-Regular',
};
// packages/ui/src/components/Text.tsx
import { Text as RNText, TextStyle, StyleSheet, Platform } from 'react-native';
interface TextProps {
children: React.ReactNode;
style?: TextStyle;
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
}
export function Text({ children, style, weight = 'regular' }: TextProps) {
const fontFamily = {
regular: 'Inter-Regular',
medium: 'Inter-Medium',
semibold: 'Inter-SemiBold',
bold: 'Inter-Bold',
}[weight];
return (
<RNText
style={[
styles.base,
{ fontFamily },
// Android needs explicit fontWeight for some system behaviors
Platform.OS === 'android' && { fontWeight: getNumericWeight(weight) },
style,
]}
>
{children}
</RNText>
);
}
function getNumericWeight(weight: string): TextStyle['fontWeight'] {
const map: Record<string, TextStyle['fontWeight']> = {
regular: '400',
medium: '500',
semibold: '600',
bold: '700',
};
return map[weight];
}
const styles = StyleSheet.create({
base: {
fontSize: 16,
lineHeight: 24,
color: '#1a1a1a',
},
});
// Option 1: Bundle fonts (larger app, faster first load)
const [fontsLoaded] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
});
// Option 2: Download from CDN (smaller app, network dependency)
// Not recommended for mobile apps - use bundling
For large fonts (especially CJK), consider subsetting:
# Using fonttools (Python)
pip install fonttools
# Subset to Latin characters only
pyftsubset MyFont.ttf --unicodes="U+0000-00FF" --output-file="MyFont-Latin.ttf"
// packages/ui/src/utils/fontPreloader.ts
import * as Font from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
// Critical fonts - load before hiding splash
const criticalFonts = {
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
};
// Secondary fonts - load after app is interactive
const secondaryFonts = {
'Inter-Light': require('../assets/fonts/Inter-Light.ttf'),
'Nunito-Regular': require('../assets/fonts/Nunito-Regular.ttf'),
};
export async function preloadCriticalFonts(): Promise<void> {
await Font.loadAsync(criticalFonts);
}
export async function preloadSecondaryFonts(): Promise<void> {
// Load in background after app is interactive
setTimeout(async () => {
await Font.loadAsync(secondaryFonts);
}, 1000);
}
// For apps with many fonts, consider unloading unused fonts
// Note: expo-font doesn't currently support unloading
// Design your font system to minimize total fonts needed
// Good: 4-6 font files
const goodFontSet = {
'Primary-Regular': '...',
'Primary-Bold': '...',
'Secondary-Regular': '...',
'Mono-Regular': '...',
};
// Bad: 20+ font files
const badFontSet = {
// Loading all weights of multiple families
};
// packages/ui/src/tokens/typography.ts
import { TextStyle } from 'react-native';
export interface TypographyTokens {
fonts: {
primary: string;
primaryMedium: string;
primaryBold: string;
secondary: string;
secondaryBold: string;
mono: string;
};
sizes: Record<string, TextStyle>;
lineHeights: Record<string, number>;
letterSpacing: Record<string, number>;
}
export const typography: TypographyTokens = {
fonts: {
primary: 'Inter-Regular',
primaryMedium: 'Inter-Medium',
primaryBold: 'Inter-Bold',
secondary: 'Nunito-Regular',
secondaryBold: 'Nunito-Bold',
mono: 'JetBrainsMono-Regular',
},
sizes: {
xs: { fontSize: 12, lineHeight: 16 },
sm: { fontSize: 14, lineHeight: 20 },
base: { fontSize: 16, lineHeight: 24 },
lg: { fontSize: 18, lineHeight: 28 },
xl: { fontSize: 20, lineHeight: 28 },
'2xl': { fontSize: 24, lineHeight: 32 },
'3xl': { fontSize: 30, lineHeight: 36 },
'4xl': { fontSize: 36, lineHeight: 40 },
'5xl': { fontSize: 48, lineHeight: 52 },
},
lineHeights: {
none: 1,
tight: 1.25,
snug: 1.375,
normal: 1.5,
relaxed: 1.625,
loose: 2,
},
letterSpacing: {
tighter: -0.8,
tight: -0.4,
normal: 0,
wide: 0.4,
wider: 0.8,
widest: 1.6,
},
};
// packages/ui/src/tokens/textStyles.ts
import { TextStyle } from 'react-native';
import { typography } from './typography';
export const textStyles: Record<string, TextStyle> = {
// Display
displayLarge: {
fontFamily: typography.fonts.primaryBold,
fontSize: 48,
lineHeight: 52,
letterSpacing: -1.5,
},
displayMedium: {
fontFamily: typography.fonts.primaryBold,
fontSize: 36,
lineHeight: 40,
letterSpacing: -1,
},
displaySmall: {
fontFamily: typography.fonts.primaryBold,
fontSize: 28,
lineHeight: 32,
letterSpacing: -0.5,
},
// Headlines
headlineLarge: {
fontFamily: typography.fonts.primaryBold,
fontSize: 24,
lineHeight: 32,
letterSpacing: 0,
},
headlineMedium: {
fontFamily: typography.fonts.primaryMedium,
fontSize: 20,
lineHeight: 28,
letterSpacing: 0,
},
headlineSmall: {
fontFamily: typography.fonts.primaryMedium,
fontSize: 18,
lineHeight: 24,
letterSpacing: 0,
},
// Body
bodyLarge: {
fontFamily: typography.fonts.primary,
fontSize: 18,
lineHeight: 28,
letterSpacing: 0.2,
},
bodyMedium: {
fontFamily: typography.fonts.primary,
fontSize: 16,
lineHeight: 24,
letterSpacing: 0.2,
},
bodySmall: {
fontFamily: typography.fonts.primary,
fontSize: 14,
lineHeight: 20,
letterSpacing: 0.2,
},
// Labels
labelLarge: {
fontFamily: typography.fonts.primaryMedium,
fontSize: 14,
lineHeight: 18,
letterSpacing: 0.3,
},
labelMedium: {
fontFamily: typography.fonts.primaryMedium,
fontSize: 12,
lineHeight: 16,
letterSpacing: 0.4,
},
labelSmall: {
fontFamily: typography.fonts.primaryMedium,
fontSize: 11,
lineHeight: 14,
letterSpacing: 0.5,
},
};
// Check font file exists
import { Asset } from 'expo-asset';
const fontAsset = Asset.fromModule(require('../assets/fonts/MyFont.ttf'));
console.log('Font URI:', fontAsset.uri);
// Verify font is loaded
import * as Font from 'expo-font';
const isLoaded = Font.isLoaded('MyFont-Regular');
console.log('Font loaded:', isLoaded);
// Use explicit font family names, not weights
// Bad: May render differently
const badStyle = { fontFamily: 'Inter', fontWeight: 'bold' };
// Good: Consistent rendering
const goodStyle = { fontFamily: 'Inter-Bold' };
// Create a default text style
// packages/ui/src/components/AppText.tsx
import { Text as RNText, TextProps, StyleSheet } from 'react-native';
export function Text(props: TextProps) {
return (
<RNText
{...props}
style={[styles.default, props.style]}
/>
);
}
const styles = StyleSheet.create({
default: {
fontFamily: 'Inter-Regular',
color: '#1a1a1a',
},
});
// TextInput requires explicit font family
<TextInput
style={{
fontFamily: 'Inter-Regular',
fontSize: 16,
}}
placeholder="Type here..."
/>
// packages/ui/src/utils/fontDebug.ts
import * as Font from 'expo-font';
export function logLoadedFonts(): void {
// List all loaded font names
console.log('Checking common font names...');
const fontNames = [
'Inter-Regular',
'Inter-Bold',
'Nunito-Regular',
// Add your fonts here
];
fontNames.forEach(name => {
console.log(`${name}: ${Font.isLoaded(name) ? 'Loaded' : 'Not loaded'}`);
});
}
export function testFontRendering(): React.ReactNode {
return (
<View>
<Text style={{ fontFamily: 'Inter-Regular' }}>Inter Regular</Text>
<Text style={{ fontFamily: 'Inter-Bold' }}>Inter Bold</Text>
{/* Add more test cases */}
</View>
);
}