Theming and Dark Mode
Create a flexible design system with light and dark mode support
Table of Contents
🎯 Learning Objectives
- Detect and respond to system color scheme preferences
- Build a comprehensive theme structure with colors, spacing, and typography
- Create a ThemeContext for app-wide theme access
- Build themed components that adapt automatically
- Implement manual theme switching (light/dark/system)
- Persist user theme preferences with AsyncStorage
- Handle status bar and navigation theming
Introduction
Dark mode isn't just a trend — it's an accessibility feature, a battery saver on OLED screens, and a user preference that modern apps must support. A well-implemented theme system makes your app adaptable, maintainable, and professional.
In this lesson, we'll build a complete theming system from scratch. By the end, you'll have reusable code that you can drop into any React Native project.
🌓 Why Theme Support Matters
- User preference: Many users prefer dark mode, especially at night
- Accessibility: Some users need high contrast or specific color schemes
- Battery life: Dark mode saves battery on OLED/AMOLED screens
- Platform expectations: iOS and Android both support system-wide dark mode
What We'll Build
flowchart TB
subgraph System["System Detection"]
CS["useColorScheme()"]
end
subgraph Theme["Theme System"]
TC["ThemeContext"]
LT["Light Theme"]
DT["Dark Theme"]
end
subgraph Components["Themed Components"]
TC --> C1["Cards"]
TC --> C2["Buttons"]
TC --> C3["Text"]
TC --> C4["Inputs"]
end
CS --> TC
TC --> LT
TC --> DT
style System fill:#e3f2fd
style Theme fill:#fff3e0
style Components fill:#e8f5e9
Detecting System Color Scheme
React Native provides the useColorScheme hook to detect the system's current color scheme preference.
Basic Usage
import { useColorScheme, View, Text, StyleSheet } from 'react-native';
function App() {
const colorScheme = useColorScheme();
// Returns: 'light' | 'dark' | null
const isDarkMode = colorScheme === 'dark';
return (
<View style={[
styles.container,
{ backgroundColor: isDarkMode ? '#121212' : '#ffffff' }
]}>
<Text style={{ color: isDarkMode ? '#ffffff' : '#000000' }}>
Current theme: {colorScheme}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
Understanding useColorScheme
| Return Value | Meaning |
|---|---|
'light' |
System is in light mode |
'dark' |
System is in dark mode |
null |
Preference not available (rare, default to light) |
✅ Automatic Updates
The useColorScheme hook automatically triggers a re-render when the user changes their system theme. No event listeners needed!
Simple Theme Selection
import { useColorScheme, View, Text } from 'react-native';
// Define color constants
const colors = {
light: {
background: '#ffffff',
text: '#1a1a1a',
primary: '#2196F3',
},
dark: {
background: '#121212',
text: '#e0e0e0',
primary: '#bb86fc',
},
};
function ThemedScreen() {
const colorScheme = useColorScheme() ?? 'light';
const theme = colors[colorScheme];
return (
<View style={{ flex: 1, backgroundColor: theme.background }}>
<Text style={{ color: theme.text }}>
Hello, themed world!
</Text>
</View>
);
}
Building a Theme Structure
A well-organized theme structure makes your styles consistent and maintainable. Let's build a comprehensive theme object.
Theme Type Definitions
// theme/types.ts
export interface ThemeColors {
// Backgrounds
background: string;
backgroundSecondary: string;
backgroundTertiary: string;
// Surfaces (cards, modals)
surface: string;
surfaceElevated: string;
// Text
text: string;
textSecondary: string;
textTertiary: string;
textInverse: string;
// Primary brand color
primary: string;
primaryLight: string;
primaryDark: string;
onPrimary: string;
// Secondary brand color
secondary: string;
onSecondary: string;
// Semantic colors
success: string;
warning: string;
error: string;
info: string;
// Borders and dividers
border: string;
divider: string;
// Interactive states
disabled: string;
placeholder: string;
}
export interface ThemeSpacing {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
xxl: number;
}
export interface ThemeTypography {
h1: TextStyle;
h2: TextStyle;
h3: TextStyle;
body: TextStyle;
bodySmall: TextStyle;
caption: TextStyle;
button: TextStyle;
}
export interface ThemeShadows {
sm: ViewStyle;
md: ViewStyle;
lg: ViewStyle;
}
export interface Theme {
dark: boolean;
colors: ThemeColors;
spacing: ThemeSpacing;
typography: ThemeTypography;
shadows: ThemeShadows;
borderRadius: {
sm: number;
md: number;
lg: number;
full: number;
};
}
Light Theme Definition
// theme/lightTheme.ts
import { Theme } from './types';
import { Platform } from 'react-native';
export const lightTheme: Theme = {
dark: false,
colors: {
// Backgrounds
background: '#ffffff',
backgroundSecondary: '#f5f5f5',
backgroundTertiary: '#eeeeee',
// Surfaces
surface: '#ffffff',
surfaceElevated: '#ffffff',
// Text
text: '#1a1a1a',
textSecondary: '#666666',
textTertiary: '#999999',
textInverse: '#ffffff',
// Primary (Blue)
primary: '#2196F3',
primaryLight: '#64b5f6',
primaryDark: '#1976d2',
onPrimary: '#ffffff',
// Secondary (Orange)
secondary: '#FF9800',
onSecondary: '#000000',
// Semantic
success: '#4CAF50',
warning: '#FFC107',
error: '#F44336',
info: '#2196F3',
// Borders
border: '#e0e0e0',
divider: '#eeeeee',
// States
disabled: '#bdbdbd',
placeholder: '#9e9e9e',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
},
typography: {
h1: {
fontSize: 32,
fontWeight: '700',
lineHeight: 40,
},
h2: {
fontSize: 24,
fontWeight: '600',
lineHeight: 32,
},
h3: {
fontSize: 20,
fontWeight: '600',
lineHeight: 28,
},
body: {
fontSize: 16,
fontWeight: '400',
lineHeight: 24,
},
bodySmall: {
fontSize: 14,
fontWeight: '400',
lineHeight: 20,
},
caption: {
fontSize: 12,
fontWeight: '400',
lineHeight: 16,
},
button: {
fontSize: 16,
fontWeight: '600',
lineHeight: 24,
},
},
shadows: {
sm: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
android: {
elevation: 2,
},
}) as any,
md: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
},
android: {
elevation: 4,
},
}) as any,
lg: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
android: {
elevation: 8,
},
}) as any,
},
borderRadius: {
sm: 4,
md: 8,
lg: 16,
full: 9999,
},
};
Dark Theme Definition
// theme/darkTheme.ts
import { Theme } from './types';
import { lightTheme } from './lightTheme';
export const darkTheme: Theme = {
...lightTheme, // Inherit spacing, typography, shadows, borderRadius
dark: true,
colors: {
// Backgrounds (Material Design dark theme guidelines)
background: '#121212',
backgroundSecondary: '#1e1e1e',
backgroundTertiary: '#2c2c2c',
// Surfaces (slightly elevated from background)
surface: '#1e1e1e',
surfaceElevated: '#2c2c2c',
// Text (reduced contrast for dark mode comfort)
text: '#e0e0e0',
textSecondary: '#a0a0a0',
textTertiary: '#6c6c6c',
textInverse: '#1a1a1a',
// Primary (lighter for dark backgrounds)
primary: '#bb86fc',
primaryLight: '#e1bee7',
primaryDark: '#9c27b0',
onPrimary: '#000000',
// Secondary
secondary: '#03DAC6',
onSecondary: '#000000',
// Semantic (slightly desaturated)
success: '#81c784',
warning: '#ffb74d',
error: '#ef5350',
info: '#64b5f6',
// Borders
border: '#3c3c3c',
divider: '#2c2c2c',
// States
disabled: '#4a4a4a',
placeholder: '#6c6c6c',
},
};
⚠️ Dark Mode Design Guidelines
- Don't use pure black (#000000): Use dark gray (#121212) for better readability
- Reduce contrast: Use #e0e0e0 instead of #ffffff for text
- Desaturate colors: Bright colors feel harsh on dark backgrounds
- Maintain hierarchy: Use slightly lighter surfaces for elevation
Creating Theme Context
Context provides theme access throughout your component tree without prop drilling.
Theme Context Implementation
// context/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { Theme } from '../theme/types';
import { lightTheme } from '../theme/lightTheme';
import { darkTheme } from '../theme/darkTheme';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const systemColorScheme = useColorScheme();
const [themeMode, setThemeMode] = useState<ThemeMode>('system');
// Determine actual theme based on mode
const isDark =
themeMode === 'dark' ||
(themeMode === 'system' && systemColorScheme === 'dark');
const theme = isDark ? darkTheme : lightTheme;
const value: ThemeContextType = {
theme,
themeMode,
setThemeMode,
isDark,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook for accessing theme
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// Convenience hook for just the theme object
export function useThemeColors() {
const { theme } = useTheme();
return theme.colors;
}
Setting Up the Provider
// App.tsx
import { ThemeProvider } from './context/ThemeContext';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
</SafeAreaProvider>
);
}
function ThemedApp() {
const { isDark } = useTheme();
return (
<>
<StatusBar style={isDark ? 'light' : 'dark'} />
<Navigation />
</>
);
}
flowchart TB
subgraph App["App Component"]
TP["ThemeProvider"]
end
subgraph Context["Theme Context"]
TM["themeMode: 'system'"]
T["theme: lightTheme | darkTheme"]
ST["setThemeMode()"]
end
subgraph Usage["Component Usage"]
UT["useTheme()"]
UTC["useThemeColors()"]
end
TP --> Context
Context --> Usage
style App fill:#e3f2fd
style Context fill:#fff3e0
style Usage fill:#e8f5e9
Building Themed Components
Create a library of themed components that automatically adapt to the current theme.
Themed Text Component
// components/themed/ThemedText.tsx
import { Text, TextProps, StyleSheet } from 'react-native';
import { useTheme } from '../../context/ThemeContext';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'bodySmall' | 'caption';
type TextColor = 'primary' | 'secondary' | 'tertiary' | 'error' | 'success' | 'brand';
interface ThemedTextProps extends TextProps {
variant?: TextVariant;
color?: TextColor;
}
export function ThemedText({
variant = 'body',
color = 'primary',
style,
...props
}: ThemedTextProps) {
const { theme } = useTheme();
const colorMap = {
primary: theme.colors.text,
secondary: theme.colors.textSecondary,
tertiary: theme.colors.textTertiary,
error: theme.colors.error,
success: theme.colors.success,
brand: theme.colors.primary,
};
return (
<Text
style={[
theme.typography[variant],
{ color: colorMap[color] },
style,
]}
{...props}
/>
);
}
// Usage
<ThemedText variant="h1">Welcome</ThemedText>
<ThemedText variant="body" color="secondary">Description</ThemedText>
<ThemedText variant="caption" color="error">Error message</ThemedText>
Themed View Component
// components/themed/ThemedView.tsx
import { View, ViewProps } from 'react-native';
import { useTheme } from '../../context/ThemeContext';
type BackgroundVariant = 'primary' | 'secondary' | 'tertiary' | 'surface' | 'elevated';
interface ThemedViewProps extends ViewProps {
background?: BackgroundVariant;
}
export function ThemedView({
background = 'primary',
style,
...props
}: ThemedViewProps) {
const { theme } = useTheme();
const backgroundMap = {
primary: theme.colors.background,
secondary: theme.colors.backgroundSecondary,
tertiary: theme.colors.backgroundTertiary,
surface: theme.colors.surface,
elevated: theme.colors.surfaceElevated,
};
return (
<View
style={[{ backgroundColor: backgroundMap[background] }, style]}
{...props}
/>
);
}
Themed Card Component
// components/themed/Card.tsx
import { View, ViewProps, StyleSheet } from 'react-native';
import { useTheme } from '../../context/ThemeContext';
interface CardProps extends ViewProps {
elevated?: boolean;
}
export function Card({ elevated = false, style, children, ...props }: CardProps) {
const { theme } = useTheme();
return (
<View
style={[
styles.card,
{
backgroundColor: elevated
? theme.colors.surfaceElevated
: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
},
elevated && theme.shadows.md,
style,
]}
{...props}
>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
padding: 16,
},
});
Themed Button Component
// components/themed/Button.tsx
import { Pressable, Text, StyleSheet, ViewStyle, Platform } from 'react-native';
import { useTheme } from '../../context/ThemeContext';
type ButtonVariant = 'filled' | 'outlined' | 'text';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
}
export function Button({
title,
onPress,
variant = 'filled',
size = 'md',
disabled = false,
}: ButtonProps) {
const { theme } = useTheme();
const sizeStyles = {
sm: { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 },
md: { paddingVertical: 12, paddingHorizontal: 20, fontSize: 16 },
lg: { paddingVertical: 16, paddingHorizontal: 24, fontSize: 18 },
};
const getButtonStyle = (): ViewStyle => {
const base: ViewStyle = {
borderRadius: theme.borderRadius.md,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: sizeStyles[size].paddingVertical,
paddingHorizontal: sizeStyles[size].paddingHorizontal,
};
if (disabled) {
return {
...base,
backgroundColor: theme.colors.disabled,
};
}
switch (variant) {
case 'filled':
return {
...base,
backgroundColor: theme.colors.primary,
...theme.shadows.sm,
};
case 'outlined':
return {
...base,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: theme.colors.primary,
};
case 'text':
return {
...base,
backgroundColor: 'transparent',
};
}
};
const getTextColor = () => {
if (disabled) return theme.colors.textTertiary;
if (variant === 'filled') return theme.colors.onPrimary;
return theme.colors.primary;
};
return (
<Pressable
onPress={onPress}
disabled={disabled}
style={({ pressed }) => [
getButtonStyle(),
Platform.OS === 'ios' && pressed && styles.pressed,
]}
android_ripple={{
color: variant === 'filled'
? 'rgba(255,255,255,0.2)'
: `${theme.colors.primary}20`,
}}
>
<Text style={[
theme.typography.button,
{ color: getTextColor(), fontSize: sizeStyles[size].fontSize },
]}>
{title}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
pressed: {
opacity: 0.7,
},
});
Themed Input Component
// components/themed/Input.tsx
import { View, TextInput, Text, StyleSheet, TextInputProps } from 'react-native';
import { useTheme } from '../../context/ThemeContext';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
}
export function Input({ label, error, style, ...props }: InputProps) {
const { theme } = useTheme();
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{label}
</Text>
)}
<TextInput
style={[
styles.input,
{
backgroundColor: theme.colors.backgroundSecondary,
color: theme.colors.text,
borderColor: error ? theme.colors.error : theme.colors.border,
borderRadius: theme.borderRadius.md,
},
style,
]}
placeholderTextColor={theme.colors.placeholder}
{...props}
/>
{error && (
<Text style={[styles.error, { color: theme.colors.error }]}>
{error}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 6,
},
input: {
height: 48,
paddingHorizontal: 16,
fontSize: 16,
borderWidth: 1,
},
error: {
fontSize: 12,
marginTop: 4,
},
});
Export All Themed Components
// components/themed/index.ts
export { ThemedText } from './ThemedText';
export { ThemedView } from './ThemedView';
export { Card } from './Card';
export { Button } from './Button';
export { Input } from './Input';
// Usage in screens
import { ThemedText, ThemedView, Card, Button, Input } from '../components/themed';
Dynamic Styles with useTheme
For more complex components, create styles dynamically based on the theme.
useThemedStyles Hook
// hooks/useThemedStyles.ts
import { useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';
import { Theme } from '../theme/types';
type StyleFactory<T> = (theme: Theme) => T;
export function useThemedStyles<T extends StyleSheet.NamedStyles<T>>(
styleFactory: StyleFactory<T>
): T {
const { theme } = useTheme();
return useMemo(() => styleFactory(theme), [theme, styleFactory]);
}
// Usage
function ProfileScreen() {
const styles = useThemedStyles((theme) => StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
padding: theme.spacing.md,
},
header: {
...theme.typography.h1,
color: theme.colors.text,
marginBottom: theme.spacing.lg,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing.md,
...theme.shadows.md,
},
label: {
...theme.typography.caption,
color: theme.colors.textSecondary,
marginBottom: theme.spacing.xs,
},
value: {
...theme.typography.body,
color: theme.colors.text,
},
}));
return (
<View style={styles.container}>
<Text style={styles.header}>Profile</Text>
<View style={styles.card}>
<Text style={styles.label}>Name</Text>
<Text style={styles.value}>John Doe</Text>
</View>
</View>
);
}
Inline Theme Usage
// For simpler cases, use theme directly
function SimpleComponent() {
const { theme } = useTheme();
return (
<View style={{
backgroundColor: theme.colors.background,
padding: theme.spacing.md,
}}>
<Text style={{
...theme.typography.h2,
color: theme.colors.text,
}}>
Title
</Text>
</View>
);
}
Style Prop Pattern
// Allow style overrides in themed components
interface ThemedCardProps extends ViewProps {
variant?: 'default' | 'outlined';
}
function ThemedCard({ variant = 'default', style, children, ...props }: ThemedCardProps) {
const { theme } = useTheme();
const cardStyle: ViewStyle = {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing.md,
...(variant === 'outlined'
? { borderWidth: 1, borderColor: theme.colors.border }
: theme.shadows.md
),
};
return (
<View style={[cardStyle, style]} {...props}>
{children}
</View>
);
}
// Usage with overrides
<ThemedCard style={{ marginBottom: 16 }}>
<Content />
</ThemedCard>
Manual Theme Switching
Allow users to choose between light, dark, or system theme preference.
Theme Selector Component
// components/ThemeSelector.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';
type ThemeOption = 'light' | 'dark' | 'system';
const options: { value: ThemeOption; label: string; icon: string }[] = [
{ value: 'light', label: 'Light', icon: '☀️' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'system', label: 'System', icon: '⚙️' },
];
export function ThemeSelector() {
const { theme, themeMode, setThemeMode } = useTheme();
return (
<View style={styles.container}>
<Text style={[styles.title, { color: theme.colors.text }]}>
Appearance
</Text>
<View style={[styles.options, { backgroundColor: theme.colors.backgroundSecondary }]}>
{options.map((option) => {
const isSelected = themeMode === option.value;
return (
<Pressable
key={option.value}
style={[
styles.option,
isSelected && { backgroundColor: theme.colors.primary },
]}
onPress={() => setThemeMode(option.value)}
>
<Text style={styles.icon}>{option.icon}</Text>
<Text style={[
styles.optionText,
{ color: isSelected ? theme.colors.onPrimary : theme.colors.text },
]}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
},
title: {
fontSize: 16,
fontWeight: '600',
marginBottom: 12,
},
options: {
flexDirection: 'row',
borderRadius: 12,
padding: 4,
},
option: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
gap: 6,
},
icon: {
fontSize: 16,
},
optionText: {
fontSize: 14,
fontWeight: '500',
},
});
Persisting Theme Preference
Save the user's theme choice so it persists between app launches.
Updated ThemeProvider with Persistence
// context/ThemeContext.tsx (updated)
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Theme } from '../theme/types';
import { lightTheme } from '../theme/lightTheme';
import { darkTheme } from '../theme/darkTheme';
type ThemeMode = 'light' | 'dark' | 'system';
const THEME_STORAGE_KEY = '@app_theme_mode';
interface ThemeContextType {
theme: Theme;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
isDark: boolean;
isLoading: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const systemColorScheme = useColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [isLoading, setIsLoading] = useState(true);
// Load saved preference on mount
useEffect(() => {
async function loadThemePreference() {
try {
const savedMode = await AsyncStorage.getItem(THEME_STORAGE_KEY);
if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
setThemeModeState(savedMode as ThemeMode);
}
} catch (error) {
console.error('Failed to load theme preference:', error);
} finally {
setIsLoading(false);
}
}
loadThemePreference();
}, []);
// Save preference when it changes
const setThemeMode = async (mode: ThemeMode) => {
setThemeModeState(mode);
try {
await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
} catch (error) {
console.error('Failed to save theme preference:', error);
}
};
const isDark =
themeMode === 'dark' ||
(themeMode === 'system' && systemColorScheme === 'dark');
const theme = isDark ? darkTheme : lightTheme;
const value: ThemeContextType = {
theme,
themeMode,
setThemeMode,
isDark,
isLoading,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Handling Loading State
// App.tsx
import { ActivityIndicator, View } from 'react-native';
import { ThemeProvider, useTheme } from './context/ThemeContext';
function App() {
return (
<ThemeProvider>
<AppContent />
</ThemeProvider>
);
}
function AppContent() {
const { isLoading, theme } = useTheme();
// Show loading while theme preference is being loaded
if (isLoading) {
return (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#121212', // Use dark to avoid flash
}}>
<ActivityIndicator size="large" color="#bb86fc" />
</View>
);
}
return <Navigation />;
}
✅ Pro Tip: Avoid White Flash
Use a dark splash screen and dark loading state to avoid a jarring white flash when dark mode users open the app. The splash screen should match the most common expected theme.
Best Practices
1. Define Semantic Colors
// ✅ Good - semantic naming
colors: {
background: '#ffffff',
text: '#1a1a1a',
textSecondary: '#666666',
error: '#f44336',
}
// ❌ Bad - literal color names
colors: {
white: '#ffffff',
black: '#1a1a1a',
gray: '#666666',
red: '#f44336',
}
2. Use Theme Tokens Consistently
// ✅ Good - use theme tokens
<View style={{ padding: theme.spacing.md }}>
<Text style={{ color: theme.colors.text }}>Hello</Text>
</View>
// ❌ Bad - hardcoded values
<View style={{ padding: 16 }}>
<Text style={{ color: '#1a1a1a' }}>Hello</Text>
</View>
3. Create Themed Component Library
// Build a complete set of themed primitives
export { ThemedText } from './ThemedText';
export { ThemedView } from './ThemedView';
export { Card } from './Card';
export { Button } from './Button';
export { Input } from './Input';
export { Divider } from './Divider';
export { Avatar } from './Avatar';
export { Badge } from './Badge';
// Then use them exclusively
import { ThemedText, Card, Button } from '../components/themed';
4. Test Both Themes
💡 Testing Checklist
- ☐ All text is readable in both themes
- ☐ Interactive elements are clearly visible
- ☐ Images/icons work on both backgrounds
- ☐ Status bar contrasts with header
- ☐ Disabled states are distinguishable
- ☐ Error/success states are visible
- ☐ No hardcoded colors bypass the theme
5. Handle Images and Icons
// For icons that need to adapt
function ThemedIcon({ name, size = 24 }) {
const { theme } = useTheme();
return <Icon name={name} size={size} color={theme.colors.text} />;
}
// For images that need dark variants
function ThemedLogo() {
const { isDark } = useTheme();
return (
<Image
source={isDark
? require('../assets/logo-dark.png')
: require('../assets/logo-light.png')
}
style={styles.logo}
/>
);
}
6. Smooth Theme Transitions
// Consider animating theme changes for smoother UX
import { LayoutAnimation, Platform, UIManager } from 'react-native';
// Enable LayoutAnimation on Android
if (Platform.OS === 'android') {
UIManager.setLayoutAnimationEnabledExperimental?.(true);
}
// In setThemeMode
const setThemeMode = async (mode: ThemeMode) => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setThemeModeState(mode);
// ... persist
};
Complete File Structure
src/
├── theme/
│ ├── index.ts # Export all theme items
│ ├── types.ts # TypeScript interfaces
│ ├── lightTheme.ts # Light theme definition
│ └── darkTheme.ts # Dark theme definition
├── context/
│ └── ThemeContext.tsx # Theme provider and hooks
├── components/
│ └── themed/
│ ├── index.ts # Export all components
│ ├── ThemedText.tsx
│ ├── ThemedView.tsx
│ ├── Card.tsx
│ ├── Button.tsx
│ └── Input.tsx
├── hooks/
│ └── useThemedStyles.ts
└── navigation/
├── theme.ts # Navigation theme adapter
└── Navigation.tsx # Themed navigator
Hands-On Exercises
Exercise 1: Create a Theme Toggle Switch
Build a toggle switch component that switches between light and dark mode, displaying the current mode.
Show Solution
import { View, Text, Switch, StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';
function ThemeToggle() {
const { theme, isDark, setThemeMode } = useTheme();
const handleToggle = (value: boolean) => {
setThemeMode(value ? 'dark' : 'light');
};
return (
<View style={[styles.container, { backgroundColor: theme.colors.surface }]}>
<View style={styles.row}>
<Text style={styles.icon}>☀️</Text>
<Switch
value={isDark}
onValueChange={handleToggle}
trackColor={{
false: theme.colors.border,
true: theme.colors.primary
}}
thumbColor={theme.colors.surface}
/>
<Text style={styles.icon}>🌙</Text>
</View>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{isDark ? 'Dark Mode' : 'Light Mode'}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
borderRadius: 12,
alignItems: 'center',
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
icon: {
fontSize: 20,
},
label: {
marginTop: 8,
fontSize: 14,
},
});
Exercise 2: Build a Themed Settings Screen
Create a complete settings screen with themed list items, a theme selector, and proper dark mode support.
Show Solution
import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../context/ThemeContext';
import { ThemeSelector } from '../components/ThemeSelector';
function SettingsScreen() {
const { theme } = useTheme();
const settingsGroups = [
{
title: 'Appearance',
items: [
{ icon: '🎨', label: 'Theme', component: <ThemeSelector /> },
],
},
{
title: 'Account',
items: [
{ icon: '👤', label: 'Profile', onPress: () => {} },
{ icon: '🔔', label: 'Notifications', onPress: () => {} },
{ icon: '🔒', label: 'Privacy', onPress: () => {} },
],
},
{
title: 'Support',
items: [
{ icon: '❓', label: 'Help Center', onPress: () => {} },
{ icon: '📧', label: 'Contact Us', onPress: () => {} },
],
},
];
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.colors.background }]}>
<ScrollView>
<Text style={[styles.screenTitle, { color: theme.colors.text }]}>
Settings
</Text>
{settingsGroups.map((group) => (
<View key={group.title} style={styles.group}>
<Text style={[styles.groupTitle, { color: theme.colors.textSecondary }]}>
{group.title}
</Text>
<View style={[styles.groupContent, { backgroundColor: theme.colors.surface }]}>
{group.items.map((item, index) => (
<View key={item.label}>
{item.component ? (
item.component
) : (
<Pressable
style={styles.settingsItem}
onPress={item.onPress}
>
<Text style={styles.itemIcon}>{item.icon}</Text>
<Text style={[styles.itemLabel, { color: theme.colors.text }]}>
{item.label}
</Text>
<Text style={{ color: theme.colors.textTertiary }}>›</Text>
</Pressable>
)}
{index < group.items.length - 1 && (
<View style={[styles.divider, { backgroundColor: theme.colors.divider }]} />
)}
</View>
))}
</View>
</View>
))}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
screenTitle: {
fontSize: 34,
fontWeight: 'bold',
padding: 16,
},
group: {
marginBottom: 24,
},
groupTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
marginLeft: 16,
marginBottom: 8,
},
groupContent: {
marginHorizontal: 16,
borderRadius: 12,
overflow: 'hidden',
},
settingsItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
itemIcon: {
fontSize: 20,
marginRight: 12,
},
itemLabel: {
flex: 1,
fontSize: 16,
},
divider: {
height: 1,
marginLeft: 52,
},
});
Exercise 3: Create a useThemedStyles Hook
Build a custom hook that creates memoized styles based on the current theme.
Show Solution
import { useMemo, useCallback } from 'react';
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from 'react-native';
import { useTheme } from '../context/ThemeContext';
import { Theme } from '../theme/types';
type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
type StyleFactory<T extends NamedStyles<T>> = (theme: Theme) => T;
export function useThemedStyles<T extends NamedStyles<T>>(
factory: StyleFactory<T>
): T {
const { theme } = useTheme();
// Memoize the factory to prevent unnecessary recreations
const stableFactory = useCallback(factory, []);
// Memoize styles based on theme
const styles = useMemo(
() => StyleSheet.create(stableFactory(theme)),
[theme, stableFactory]
);
return styles;
}
// Usage example
function ProfileCard() {
const styles = useThemedStyles((theme) => ({
container: {
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing.md,
...theme.shadows.md,
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: theme.colors.primary,
marginBottom: theme.spacing.sm,
},
name: {
...theme.typography.h3,
color: theme.colors.text,
},
bio: {
...theme.typography.body,
color: theme.colors.textSecondary,
marginTop: theme.spacing.xs,
},
}));
return (
<View style={styles.container}>
<View style={styles.avatar} />
<Text style={styles.name}>John Doe</Text>
<Text style={styles.bio}>Software Developer</Text>
</View>
);
}
Summary
Congratulations! You've completed Module 4 and built a complete theming system with dark mode support.
🎯 Key Takeaways
- useColorScheme detects system theme preference automatically
- Theme structure should include colors, spacing, typography, and shadows
- ThemeContext provides app-wide theme access
- Themed components adapt automatically to theme changes
- Theme selector allows users to choose light, dark, or system
- AsyncStorage persists theme preference between sessions
- Navigation theming requires passing theme to NavigationContainer
Module 4 Complete! 🎉
You've now mastered React Native styling:
| Lesson | Topic |
|---|---|
| 4.1 | StyleSheet Fundamentals |
| 4.2 | Flexbox in React Native |
| 4.3 | Common Layout Patterns |
| 4.4 | Responsive Design |
| 4.5 | Platform-Specific Styles |
| 4.6 | Animation Basics |
| 4.7 | Theming and Dark Mode ✓ |
Quick Reference
// Detect system theme
const colorScheme = useColorScheme(); // 'light' | 'dark' | null
// Use theme context
const { theme, isDark, setThemeMode } = useTheme();
// Apply themed styles
<View style={{ backgroundColor: theme.colors.background }}>
<Text style={{ color: theme.colors.text }}>Hello</Text>
</View>
// Themed components
import { ThemedText, Card, Button } from './components/themed';
// Persist preference
await AsyncStorage.setItem('@theme_mode', 'dark');
const saved = await AsyncStorage.getItem('@theme_mode');
Coming Up Next
In Module 5: Navigation Fundamentals, you'll learn how to build complete navigation systems with React Navigation, including stack navigation, tab navigation, drawer navigation, and deep linking.