Step 0: the app.json line that breaks everything
Before any code: Expo defaults userInterfaceStyle to "light". With that default, useColorScheme() returns 'light' on iOS no matter what the phone is set to, and you will spend an hour debugging a hook that works fine.
// app.json
{
"expo": {
"userInterfaceStyle": "automatic",
"ios": { "userInterfaceStyle": "automatic" },
"android": { "userInterfaceStyle": "automatic" }
}
}This is native config, so it takes a rebuild — npx expo prebuild plus a new dev build, not a Fast Refresh. If you’re still on Expo Go this works out of the box; the difference matters once you move to a development build.
Step 1: semantic tokens, not color values
The single decision that determines whether dark mode stays maintainable: components must never name a color. They name a role — surface, textMuted, border — and the theme decides what that role looks like. The day you add a third theme (AMOLED black, high contrast) you change one file instead of two hundred components.
| Token | Light | Dark | Used for |
|---|---|---|---|
background | #FFFFFF | #121212 | Screen background |
surface | #F5F5F5 | #1E1E1E | Cards, sheets, inputs |
surfaceRaised | #FFFFFF | #252525 | Modals, popovers (lighter = closer) |
text | rgba(0,0,0,0.87) | rgba(255,255,255,0.87) | Primary copy |
textMuted | rgba(0,0,0,0.55) | rgba(255,255,255,0.55) | Captions, meta |
border | rgba(0,0,0,0.10) | rgba(255,255,255,0.10) | Dividers, outlines |
accent | #EA580C | #FB923C | Buttons, links — lighter in dark |
danger | #DC2626 | #F87171 | Destructive actions |
Two things in that table are deliberate and commonly gotten wrong:
- Dark background is #121212, not #000. Pure black against pure white text causes halation — the text appears to smear on OLED. Near-black also lets you show elevation by making raised surfaces lighter, which is the only elevation cue you have in dark mode because shadows are invisible.
- The accent gets lighter in dark mode. A brand orange tuned for a white background fails contrast on a dark one. Shift accents up 100–200 in your color scale for the dark theme rather than reusing the same hex.
Step 2: the theme provider with a real override
Users expect three choices, not two: Light, Dark, and System. Store the preference and derive the resolvedtheme from it — storing the resolved value is the bug that makes “System” stop following the OS after the first launch.
// theme/ThemeProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { lightTokens, darkTokens } from './tokens';
type Pref = 'light' | 'dark' | 'system';
const KEY = 'theme-pref';
const ThemeContext = createContext({
colors: lightTokens,
scheme: 'light' as 'light' | 'dark',
pref: 'system' as Pref,
setPref: (_p: Pref) => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const system = useColorScheme(); // 'light' | 'dark' | null
const [pref, setPrefState] = useState<Pref>('system');
useEffect(() => {
AsyncStorage.getItem(KEY).then((v) => {
if (v === 'light' || v === 'dark' || v === 'system') setPrefState(v);
});
}, []);
const setPref = (p: Pref) => {
setPrefState(p);
AsyncStorage.setItem(KEY, p); // store the PREFERENCE, not the result
};
const scheme = pref === 'system' ? (system ?? 'light') : pref;
const colors = scheme === 'dark' ? darkTokens : lightTokens;
return (
<ThemeContext.Provider value={{ colors, scheme, pref, setPref }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);One caveat worth knowing before you ship: the theme is system for the few milliseconds before AsyncStorage resolves. If a user has forced Light on a dark phone, they see a dark flash at launch. Either gate the splash screen on that read (SplashScreen.preventAutoHideAsync()) or move the preference to expo-sqlite/kv-store or MMKV, which read synchronously.
Step 3: the four things that leak light
Your screens are themed and it still looks broken. It’s almost always one of these four, none of which live inside your components:
// app/_layout.tsx — everything below the provider
import { StatusBar } from 'expo-status-bar';
import { ThemeProvider as NavThemeProvider, DarkTheme, DefaultTheme }
from '@react-navigation/native';
import { Stack } from 'expo-router';
import { useTheme } from '../theme/ThemeProvider';
function Root() {
const { scheme, colors } = useTheme();
return (
<NavThemeProvider
value={{
...(scheme === 'dark' ? DarkTheme : DefaultTheme),
colors: {
...(scheme === 'dark' ? DarkTheme : DefaultTheme).colors,
background: colors.background, // 1. kills the white flash between screens
card: colors.surface,
text: colors.text,
border: colors.border,
},
}}
>
{/* 2. status bar text flips with the theme */}
<StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />
<Stack screenOptions={{ contentStyle: { backgroundColor: colors.background } }} />
</NavThemeProvider>
);
}- Navigator background. React Navigation paints its own background under your screens. Unthemed, you get a white strobe on every push in dark mode.
- Status bar. Dark text on a dark bar is invisible. Drive
expo-status-barfrom the resolved theme. - The native splash screen. It has its own background color in
app.json, and Expo supports adarkvariant. A white splash into a dark app looks like a crash. - Images and maps. Transparent PNG logos drawn in black disappear. Give logos a light variant, and set the map to a dark style — the map is usually the last white rectangle left.
The NativeWind path (fewer lines, same rules)
If you’re on NativeWind, you get dark: variants and skip most of the token plumbing — but you still need the app.json fix and the four leaks above.
// tailwind.config.js
module.exports = {
darkMode: 'class',
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
};
// Any component:
<View className="bg-white dark:bg-neutral-900 p-4 rounded-2xl">
<Text className="text-neutral-900 dark:text-neutral-100 font-semibold">
Works in both themes
</Text>
</View>
// Manual override — drives every dark: class at once:
import { useColorScheme } from 'nativewind';
const { colorScheme, setColorScheme } = useColorScheme();
setColorScheme('dark'); // 'light' | 'dark' | 'system'Note the import: useColorScheme from nativewind is a different hook from the one in react-native. Only the NativeWind one has setColorScheme. Importing the wrong one is the most common reason a toggle button appears to do nothing.
How to test it in five minutes
- Toggle the system theme while the app is open. Everything should re-render live. Anything that needs a restart is reading the scheme once instead of subscribing.
- Cold-launch in each theme and watch the first 400ms for a flash — that’s the splash or the preference read.
- Grep for hardcoded colors:
grep -rn "#fff\|#FFF\|'white'" app components. Every hit is a future bug report. - Check contrast on muted text in dark mode specifically — 55% white on #121212 is the usual failure.
Or start with it already wired
Theming is the kind of thing that costs an afternoon to retrofit and nothing to have from the start. Apps generated by ShipNative come out with a token layer, a light/dark/system toggle in Settings, and the navigator and status bar already driven by the resolved theme — you get the real Expo project, so the code above is yours to change. Describe your app in a sentence and see it running on your phone in minutes.