Theming and Dark Mode
Read the system appearance with useColorScheme, keep colours in a tokens file, and pass a theme down through context.
const colorScheme = useColorScheme(); // 'light' | 'dark' | nullDark mode is not a stylesheet switch here. You read the current appearance and render the matching colours. useColorScheme from react-native returns 'light', 'dark' or null, the last when the native Appearance module is unavailable, and re-renders when the system setting changes. In Expo, the app config key "userInterfaceStyle" must be "automatic" for the app to follow the system at all; "light" or "dark" lock it. The Expo docs add that a development build on Android also needs expo-system-ui installed.
Tokens, not hex codes
Put every colour in one module as two sets of the same keys, light and dark. Screens then name a role, colors.text or colors.card, never a hex code. When a designer changes the palette you edit one file, and a missing dark value becomes a type error rather than white text on white.
Getting the theme to the leaves
Passing colors through every component as a prop does not scale. createContext from React makes one value readable by every component below a provider, with no props in between; useContext(ThemeContext) reads it. Wrapping that read in your own useTheme() hook is the shorthand for the same useContext call.
The catch: StyleSheet.create runs once at import, so it cannot contain a colour that depends on the theme. Keep structural styles in the sheet and apply colours in an array entry, style={[styles.card, { backgroundColor: colors.card }]}. Offer the reader System, Light and Dark rather than assuming; Appearance.setColorScheme applies an in-app override.
Example
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import { StyleSheet, Text, View, useColorScheme } from 'react-native';
// One tokens object, the same keys in both schemes.
const tokens = {
light: { bg: '#ffffff', card: '#f2f2f7', text: '#1c1c1e', muted: '#636366' },
dark: { bg: '#000000', card: '#1c1c1e', text: '#f2f2f7', muted: '#98989f' },
};
type Theme = { colors: typeof tokens.light; scheme: 'light' | 'dark' };
const ThemeContext = createContext<Theme>({
colors: tokens.light,
scheme: 'light',
});
export function ThemeProvider({ children }: { children: ReactNode }) {
// useColorScheme may return null, so fall back rather than index with it.
const scheme = useColorScheme() === 'dark' ? 'dark' : 'light';
const value = useMemo(() => ({ colors: tokens[scheme], scheme }), [scheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// This hook is the shorthand for the useContext call inside it.
export function useTheme(): Theme {
return useContext(ThemeContext);
}
export default function ThemingDemo() {
const { colors } = useTheme();
return (
<View style={[styles.screen, { backgroundColor: colors.bg }]}>
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.title, { color: colors.text }]}>Appearance</Text>
<Text style={{ color: colors.muted }}>Follows the system setting.</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
// No colours here: the sheet is built once, at import time.
screen: { flex: 1, padding: 16 },
card: { padding: 16, borderRadius: 12, gap: 4 },
title: { fontSize: 20, fontWeight: '700' },
});When to use it
- Follow the phone's dark mode without the reader configuring anything.
- Replace scattered hex codes with named tokens so a palette change is one edit.
- Give the reader a System, Light, Dark control in settings.
- Read theme colours deep in a component tree without threading props through it.
More examples
Letting the reader override the system setting
Three buttons and a line showing the scheme in force. Appearance.setColorScheme overrides the OS for this app only, and passing 'auto' hands control back to the system. Offering System as the default choice is the point: a reader who has set their phone to switch at sunset expects your app to follow.
import { Appearance, Pressable, StyleSheet, Text, View, useColorScheme } from 'react-native';
type Choice = 'light' | 'dark' | 'auto';
export default function AppearancePicker() {
const scheme = useColorScheme(); // reflects the override once it is set
// 'auto' restores the system setting; 'light' or 'dark' pins the app.
const choose = (next: Choice) => Appearance.setColorScheme(next);
return (
<View style={styles.row}>
<Pressable style={styles.button} onPress={() => choose('auto')}>
<Text style={styles.label}>System</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => choose('light')}>
<Text style={styles.label}>Light</Text>
</Pressable>
<Pressable style={styles.button} onPress={() => choose('dark')}>
<Text style={styles.label}>Dark</Text>
</Pressable>
<Text style={styles.label}>{`now: ${scheme ?? 'unknown'}`}</Text>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 16 },
button: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8, backgroundColor: '#e5e5ea' },
label: { fontSize: 14, color: '#1c1c1e' },
});Building a themed sheet once per scheme
A bordered row whose background, border and text colour all come from the active scheme, with no colour literals sprinkled through the JSX. Use the factory form when a screen has many themed properties and the array-with-inline-colour approach starts to clutter the markup; use the inline form when only one or two properties change.
import { useMemo } from 'react';
import { StyleSheet, Text, View, useColorScheme } from 'react-native';
const palette = {
light: { bg: '#ffffff', text: '#1c1c1e', border: '#c7c7cc' },
dark: { bg: '#000000', text: '#f2f2f7', border: '#3a3a3c' },
};
// A factory, so StyleSheet.create still runs with concrete values.
const makeStyles = (c: typeof palette.light) =>
StyleSheet.create({
screen: { flex: 1, padding: 16, backgroundColor: c.bg },
row: {
padding: 12,
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
borderColor: c.border,
},
text: { fontSize: 16, color: c.text },
});
export default function ThemedSheet() {
const scheme = useColorScheme() === 'dark' ? 'dark' : 'light';
// useMemo keeps one sheet per scheme instead of one per render.
const styles = useMemo(() => makeStyles(palette[scheme]), [scheme]);
return (
<View style={styles.screen}>
<View style={styles.row}>
<Text style={styles.text}>Themed without inline colour objects</Text>
</View>
</View>
);
}
Discussion