Implementing Dark Mode in React Native Apps
A practical guide to implementing dark mode in React Native with theme context, system preference detection, and NativeWind styling.
## Why Dark Mode Matters
Dark mode isn't a nice-to-have. It's expected. According to Android Authority, over 80% of smartphone users prefer dark mode. Apple and Google both feature dark mode support as a factor in app store editorial consideration.
Beyond user preference, dark mode reduces battery consumption on OLED screens by up to 60%. On devices where battery life is a constant concern, that's significant.
We implement dark mode in every app we build. Here's exactly how we do it in React Native with Expo.
## The Architecture
There are three layers to a good dark mode implementation:
1. **Theme context** - A React context that holds the current theme and colors 2. **System preference detection** - Respect the user's OS-level setting 3. **User override** - Let users choose light, dark, or system default
Let's build each layer.
## Step 1: Define Your Color Palette
Start with two complete color palettes. Don't just invert colors. Dark mode needs its own design consideration.
```typescript // lib/theme.ts export const lightColors = { background: '#FFFFFF', surface: '#F5F5F5', card: '#FFFFFF', text: '#1A1A1A', textSecondary: '#666666', border: '#E5E5E5', primary: '#FF4500', primaryText: '#FFFFFF', success: '#22C55E', warning: '#F59E0B', error: '#EF4444', };
export const darkColors = { background: '#0D0D0F', surface: '#111114', card: '#1A1A1E', text: '#F5F5F5', textSecondary: '#9CA3AF', border: '#1E1E24', primary: '#FF4500', primaryText: '#FFFFFF', success: '#22C55E', warning: '#F59E0B', error: '#EF4444', };
export type ThemeColors = typeof lightColors; ```
Notice that some colors stay the same across themes (primary, success, warning, error). Your brand color should be consistent. The background, surface, text, and border colors are what change.
### Dark Mode Design Tips
- **Don't use pure black (#000000).** Use dark gray (#0D0D0F or #111114). Pure black creates too much contrast with white text and causes visual strain. - **Reduce white text opacity.** Use #F5F5F5 instead of #FFFFFF for body text. Full white on dark backgrounds is harsh. - **Increase card elevation.** In dark mode, lighter surfaces feel "elevated." Use slightly lighter grays for cards and modals. - **Test color contrast.** WCAG requires a 4.5:1 contrast ratio for normal text. Dark mode makes it easy to accidentally fail this.
## Step 2: Create the Theme Context
```typescript // context/ThemeContext.tsx import React, { createContext, useContext, useState, useEffect } from 'react'; import { useColorScheme } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { lightColors, darkColors, ThemeColors } from '../lib/theme';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType { mode: ThemeMode; isDark: boolean; colors: ThemeColors; setMode: (mode: ThemeMode) => void; }
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) { const systemScheme = useColorScheme(); const [mode, setModeState] = useState<ThemeMode>('system');
useEffect(() => { AsyncStorage.getItem('theme_mode').then((saved) => { if (saved === 'light' || saved === 'dark' || saved === 'system') { setModeState(saved); } }); }, []);
const setMode = (newMode: ThemeMode) => { setModeState(newMode); AsyncStorage.setItem('theme_mode', newMode); };
const isDark = mode === 'system' ? systemScheme === 'dark' : mode === 'dark';
const colors = isDark ? darkColors : lightColors;
return ( <ThemeContext.Provider value={{ mode, isDark, colors, setMode }}> {children} </ThemeContext.Provider> ); }
export function useThemeColors() { const context = useContext(ThemeContext); if (!context) throw new Error('useThemeColors must be used within ThemeProvider'); return context; } ```
Key decisions in this implementation:
- **Default to 'system'**: Respect the user's OS preference out of the box - **Persist with AsyncStorage**: Remember the user's choice across app launches - **Simple API**: Components just call `useThemeColors()` and get the current palette
## Step 3: Wire It Into Your App
Wrap your root layout with the ThemeProvider:
```typescript // app/_layout.tsx import { ThemeProvider } from '../context/ThemeContext'; import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function RootLayout() { return ( <SafeAreaProvider> <ThemeProvider> <Stack screenOptions={{ headerShown: false }} /> </ThemeProvider> </SafeAreaProvider> ); } ```
## Step 4: Use Theme Colors in Components
```typescript // screens/HomeScreen.tsx import { useThemeColors } from '../context/ThemeContext';
export default function HomeScreen() { const { colors } = useThemeColors();
return ( <View style={{ flex: 1, backgroundColor: colors.background }}> <Text style={{ color: colors.text, fontSize: 24 }}> Welcome back </Text> <View style={{ backgroundColor: colors.card, borderRadius: 16, borderWidth: 1, borderColor: colors.border, padding: 16, }}> <Text style={{ color: colors.textSecondary }}> Your daily summary </Text> </View> </View> ); } ```
### Why Inline Styles, Not NativeWind?
You might expect us to use NativeWind's `className="bg-gray-900 dark:bg-white"` approach. We've learned the hard way that NativeWind's arbitrary value syntax (like `bg-[#0D0D0F]`) can cause issues in production builds, especially with Expo's bundler.
Inline styles with theme context are more reliable, easier to debug, and work consistently across development and production. The trade-off is slightly more verbose code, but the reliability is worth it.
## Step 5: Add a Theme Picker
Give users control in your settings screen:
```typescript function ThemeSelector() { const { mode, setMode, colors } = useThemeColors();
const options: { label: string; value: ThemeMode }[] = [ { label: 'Light', value: 'light' }, { label: 'Dark', value: 'dark' }, { label: 'System', value: 'system' }, ];
return ( <View> {options.map((option) => ( <Pressable key={option.value} onPress={() => setMode(option.value)} style={{ flexDirection: 'row', justifyContent: 'space-between', padding: 16, backgroundColor: mode === option.value ? colors.primary + '20' : colors.card, }} > <Text style={{ color: colors.text }}>{option.label}</Text> {mode === option.value && ( <Text style={{ color: colors.primary }}>Selected</Text> )} </Pressable> ))} </View> ); } ```
## Handling Navigation Bar and Status Bar
Don't forget the system UI elements:
```typescript import { StatusBar } from 'expo-status-bar';
function App() { const { isDark } = useThemeColors();
return ( <> <StatusBar style={isDark ? 'light' : 'dark'} /> {/* rest of your app */} </> ); } ```
For React Navigation, set the theme dynamically:
```typescript import { DarkTheme, DefaultTheme } from '@react-navigation/native';
const navTheme = isDark ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: colors.background, card: colors.card, border: colors.border, }, } : DefaultTheme; ```
## Testing Dark Mode
Test these scenarios:
1. **Fresh install**: App should respect system preference 2. **Switch system setting**: App should update in real-time (when set to "system") 3. **Manual override**: User sets "dark" while system is "light" (and vice versa) 4. **Persistence**: Kill the app, reopen. Theme should be remembered. 5. **All screens**: Every screen, modal, and bottom sheet needs theming 6. **Images and icons**: Do your images work on both backgrounds? 7. **Contrast ratios**: Run an accessibility checker on both modes
## Common Pitfalls
- **Hardcoded colors**: Search your entire codebase for hex values and RGB values. Every one should come from your theme context. - **Third-party components**: Libraries like react-native-modal or react-native-dropdown often have their own background colors. Override them with your theme colors. - **Splash screen**: Your splash screen should match the user's current theme. Expo's SplashScreen can be configured with a dark variant. - **Screenshots for the app store**: Take screenshots in both light and dark mode. Feature both in your app store listing.
Dark mode is one of those features that seems simple on the surface but touches every component in your app. Take the time to do it right, and your users will notice the quality.
For a production-ready implementation with all these patterns baked in, check out how [our build pipeline](/features/building) handles theming across every generated app, or explore our [React Native tech stack](/tech/react-native).