Dark Mode in Expo: the Short Answer
Adding dark mode to an Expo app comes down to three things: detect which scheme the system is using, maintain a typed color palette for each scheme, and make sure every visual value in every component reads from that palette instead of being hardcoded. When all three are in place, the system switch from light to dark takes the whole app with it instantly - no per-screen logic, no conditional styles scattered through components, no manual toggle to wire up.
The detection is one hook. The palette is one file. The hard part is the sweep - finding every '#fff', every '#000', every hardcoded gray that someone wrote directly onto a component and replacing it with a theme token. That sweep is exactly the kind of mechanical work Claude Code handles well, which is why builders inside App Store Launch Club routinely add dark mode to a whole app in a single session. The steps below are the exact sequence that works.
Step 1 - Tell Expo the App Supports Both Schemes
Before any code changes, open app.json and set userInterfaceStyle to 'automatic'. This tells iOS and Android that the app is intentionally opt-in to both light and dark, rather than locked to one. Without this field, the platform may not send color scheme change events to your app at all, which means your useColorScheme hook always returns the same value and dark mode never fires.
- Set userInterfaceStyle: 'automatic' at the top level in app.json (applies to both platforms).
- On iOS you can also set ios.userInterfaceStyle: 'automatic' if you want to be explicit.
- On Android, Expo wires this through the root theme, so the top-level setting is usually enough.
- Run expo start and toggle the system appearance in the simulator or device settings - your app should now receive the change before you have written any color logic.
This single config change is the prerequisite. Developers skip it and spend an hour debugging why their hook does not respond. Check app.json first, every time.
Step 2 - Detect the Scheme With useColorScheme
React Native ships useColorScheme and Expo re-exports it. Call it at the top of your root component or inside a context provider and it returns 'light', 'dark', or null (null means the system preference is not readable, which you treat as light). The hook is reactive: when the user toggles their system appearance, the hook re-runs and your component re-renders.
The pattern that scales is to read the scheme once at the root, resolve it to a theme object, and push that object down through a React context so every component can read the theme without re-calling the hook. Calling useColorScheme in every leaf component works but is noisy. A single ThemeContext at the root is cleaner and easier to extend if you later add a manual override toggle.
Step 3 - Build a Typed Theme Object
A theme object is a plain JavaScript object that maps semantic token names to color values. You keep two versions - one for light, one for dark - and your context picks the right one based on the scheme. The token names are the entire point: instead of hardcoding '#1a1a1a' on a component, you write colors.text, and when the scheme switches, colors.text is already '#f2f2f2'. No component changes. The theme swaps under it.
Example light and dark palette tokens
| Token | Light value | Dark value |
|---|---|---|
| background | #ffffff | #121212 |
| surface | #f5f5f5 | #1e1e1e |
| text | #1a1a1a | #f2f2f2 |
| textSecondary | #6b6b6b | #a0a0a0 |
| border | #e0e0e0 | #2e2e2e |
| primary | #fa4d01 | #fa4d01 |
Keep the token names semantic, not descriptive. 'text' not 'darkGray'. 'surface' not 'lightGray'. Semantic names survive a redesign: if you decide the dark surface should be slightly warmer, you change one hex value in one place. Descriptive names break as soon as the color stops matching the name. The token set above covers the majority of screens. Add tokens only when an existing one does not fit - the right size for a theme file is small.
Step 4 - Wire the Theme Through Context
Create a ThemeContext that holds the resolved theme object and a ThemeProvider that wraps your app root. The provider reads useColorScheme, picks the matching palette, and passes it as context value. Any component anywhere in the tree then calls useTheme() to get the colors without prop drilling. This is the pattern that makes dark mode feel effortless once it is set up - each new screen you add just calls useTheme and gets the right colors automatically.
If you want a manual override (a toggle in settings that lets users lock to light or dark regardless of their system setting), add a state value to the provider and an updateScheme function in context. The override stores in AsyncStorage so it survives app restarts. The manual toggle reads that stored value first, falls back to the system scheme, and passes the resolved final scheme to the palette picker. This is an additive change on top of the basic setup - get the system-detection path right first, then layer the override in.
Step 6 - Sweep Hardcoded Colors With Claude Code
Open Claude Code's desktop app in your project folder and give it a straightforward instruction: find every hardcoded color string in the components and screens directories, replace each with the matching token from the theme object, and add a useTheme call to any component that does not already import it. Because Claude Code can read the whole project at once, it catches the hardcoded values in StyleSheet.create calls, in inline style props, in platform-specific branches, and in component libraries you have wrapped.
The output is worth reviewing screen by screen before committing. Sometimes a hardcoded color is intentional - a brand accent that should never change between schemes, or a temporary debug background. Glance at the diff and keep those. Everything else - the backgrounds, the text colors, the borders - should now read from the theme. Run the app, toggle between light and dark, and walk through every screen. Any screen that does not respond has a remaining hardcoded value. Spot-fixing those by hand takes minutes once the bulk sweep is done. When the full sweep is clean, committing a dark mode implementation takes under a day even on a large app.
Building with Claude Code on a real Expo app is exactly what we cover inside App Store Launch Club. Members work through features like this in the community and share what actually hit them - the null scheme edge case, the navigator theme prop, the status bar mismatch. Join at applaunchclub.co for $9 a month and get your builds reviewed by people who have shipped the same features.
Short, practical drops on app ideas, validating fast, store listings, and passing app review. No spam, unsubscribe anytime.
Frequently asked questions
How do I detect dark mode in an Expo app?
Use the useColorScheme hook from React Native (Expo re-exports it). It returns 'light', 'dark', or null. Coerce null to 'light' as a safe default. The hook is reactive - when the user changes their system appearance, the hook fires and your component re-renders. Also set userInterfaceStyle: 'automatic' in app.json so the platform actually sends scheme change events to your app.
What is userInterfaceStyle and why does it matter?
It is an Expo config field in app.json that tells iOS and Android whether your app supports light only, dark only, or both ('automatic'). Without 'automatic', the system may ignore scheme changes entirely and your useColorScheme hook will always return the same value. Set it to 'automatic' as the very first step before writing any color code.
How do I theme React Navigation for dark mode?
Pass the theme prop to NavigationContainer. Import DefaultTheme and DarkTheme from @react-navigation/native, then pass theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}. This makes the header, tab bar, and drawer background respond to the scheme automatically without touching individual navigator screens.
Can I let users override the system appearance and lock to light or dark?
Yes. Add a state value to your ThemeProvider that stores a manual override. Read it from AsyncStorage on mount so it persists across restarts. In the provider, use the stored override if it exists, otherwise fall back to useColorScheme. Expose an updateScheme function through context so a settings screen can call it. The system detection and the manual override stack cleanly on top of each other.
Why does my app stay in light mode even after I toggle the system setting?
The most common cause is a missing userInterfaceStyle: 'automatic' in app.json. Without it, iOS and Android may not send color scheme events to the app. The second cause is reading useColorScheme outside of a live component (for example in a static StyleSheet.create call at module load time) - the scheme is read once at module load and never updates. Move the color resolution inside a component or context that re-renders on scheme changes.
Last reviewed by David on September 13, 2026


