How to Add Dark Mode to Your Expo App

DavidDavid September 13, 2026 9 min read
A sleek smartphone showing two side-by-side app screens, one bright white and one deep charcoal, resting on a dark matte desk beside a small toggle switch component and warm amber desk lamp
Original image, App Store Launch Club

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

TokenLight valueDark 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 5 - Handle React Navigation and Expo System Components

React Navigation's NavigationContainer accepts a theme prop. Pass it the DefaultTheme or DarkTheme from @react-navigation/native, swapping based on your resolved scheme. This makes the navigator's header, tab bar, and background respond to the scheme without any per-screen work. It is the highest-leverage single step in a real app: one prop on the root container and the entire navigation chrome adapts.

  1. Pass theme={scheme === 'dark' ? DarkTheme : DefaultTheme} to NavigationContainer.
  2. Set the StatusBar style prop: style='light' on dark backgrounds, style='dark' on light ones. Expo's StatusBar component does this with one prop.
  3. Wrap your root in SafeAreaProvider and use SafeAreaView with backgroundColor from your theme - the safe area insets should match the screen, not be transparent.
  4. If you use a Modal or BottomSheet, check its background color prop - modals often default to white and need to be themed explicitly.

After handling navigation and system components, build the app and toggle the scheme. The chrome will look right. What remains is the sweep through your screens - every component that still has a hardcoded color. That sweep is what Claude Code accelerates.

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.

Free app-building tips, straight to your inbox

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

David

Written by

David

Founder and app builder

Keep reading

App StoreLaunch

App Store Phased Release: How the 7-Day Rollout Works, How to Pause It, and When to Skip It

App Store phased release rolls a version update out to a random sample of users with automatic updates on over 7 days: 1 percent on day one, then 2, 5, 10, 20, 50 and 100. You can pause it for up to 30 days in total, or release to everyone with one button. Here is exactly what Apple says it does, what it does not do, and how I decide when an Expo app update should use it.

David 8 min
Read article
ExpoClaude Code

How to Add Haptic Feedback to Your Expo App

How to add haptic feedback to your Expo app: install expo-haptics, then call the right method for the moment - selectionAsync for pickers, impactAsync for taps and gestures, notificationAsync for outcomes. Here is the exact mapping, the reason the simulator stays silent, and the one rule that keeps haptics from feeling cheap.

David 8 min
Read article

Ready to build it yourself?

Join App Store Launch Club, the #1 community for building and launching apps, for $9/month.

← Back to the blog