Dark mode stopped being a "nice to have" years ago. In 2026, both iOS and Android default to user-selected color schemes, App Store reviewers flag jarring white flashes, and users honestly just expect a toggle that remembers their choice the next time they open the app. Yet most React Native tutorials still stop at useColorScheme() and call it a day — leaving you with a half-broken experience: splash screens that flash white, status bars that vanish into the background, modals that ignore the theme, and a "system / light / dark" picker that resets on every reload.
So, let's actually fix all of that. This guide walks through a production-grade dark mode implementation for an Expo app in 2026. We'll cover the React Native useColorScheme hook, NativeWind v4's dark: variant, persistent user preferences with MMKV, the native status bar and navigation bar, the splash screen color, and the small gotchas that only show up on real hardware.
What "dark mode" actually means in a React Native app
There are three independent layers you need to control, and confusion between them is — in my experience — the single biggest source of bugs:
- The system color scheme — set by the OS in Settings > Display. React Native exposes this via
useColorScheme()fromreact-native. - The user's in-app preference — "Follow system", "Always light", or "Always dark". That's your own state, persisted to disk.
- The native chrome — the status bar, the Android navigation bar, the splash screen background, the iOS launch screen. None of these are React components, and they won't update from JavaScript state changes alone.
A correct implementation merges (1) and (2) into an "effective theme", applies it to every JS-rendered component, and then mirrors the result to the native chrome so the transition feels seamless.
Starting from a fresh Expo project
If you're starting from scratch, the SDK 55 template already bundles most of what we need. Otherwise, just install the pieces into an existing project:
npx create-expo-app@latest my-themed-app
cd my-themed-app
npx expo install expo-system-ui expo-status-bar expo-splash-screen react-native-mmkv
npm install nativewind tailwindcss
Each package has a specific job:
expo-system-ui— sets the native root view background color, so there's no flash during navigation.expo-status-bar— controls the status bar style (light/dark icons) from JS.expo-splash-screen— lets you delay hiding the launch image until the theme is hydrated.react-native-mmkv— synchronous, roughly 30x faster than AsyncStorage, so you can store the theme choice without an async hop on launch.nativewind— Tailwind for React Native, with first-classdark:variant support in v4.
Using useColorScheme correctly
The naive first attempt usually looks like this:
import { useColorScheme } from 'react-native';
export default function Screen() {
const scheme = useColorScheme();
return (
<View style={{ backgroundColor: scheme === 'dark' ? '#000' : '#fff' }}>
...
</View>
);
}
It works — kind of. But it has three real problems. First, every component re-reads the OS scheme on its own, so you can't override it with an in-app toggle. Second, on Android there's a short window during cold start where useColorScheme() returns null before the native value arrives, which causes a one-frame flash. And third, it does nothing at all for the status bar.
The fix is to centralize the theme state (in a context or a Zustand store) and feed it to both your styled components and the native chrome.
Building a theme provider with persistence
We want three values: the user's choice ('system' | 'light' | 'dark'), the effective theme that components actually consume, and a setter. MMKV gives us synchronous reads, which means we can hydrate the choice before the first render and dodge the flash entirely.
// theme/ThemeProvider.tsx
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { useColorScheme, Appearance } from 'react-native';
import { MMKV } from 'react-native-mmkv';
import * as SystemUI from 'expo-system-ui';
type ThemeChoice = 'system' | 'light' | 'dark';
type EffectiveTheme = 'light' | 'dark';
const storage = new MMKV({ id: 'theme' });
const KEY = 'choice';
const ThemeContext = createContext<{
choice: ThemeChoice;
theme: EffectiveTheme;
setChoice: (c: ThemeChoice) => void;
}>({ choice: 'system', theme: 'light', setChoice: () => {} });
const COLORS = {
light: { background: '#ffffff', text: '#0a0a0a' },
dark: { background: '#0a0a0a', text: '#f5f5f5' },
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const system = useColorScheme();
const [choice, setChoiceState] = useState<ThemeChoice>(
() => (storage.getString(KEY) as ThemeChoice) ?? 'system'
);
const theme: EffectiveTheme = useMemo(() => {
if (choice === 'system') return system === 'dark' ? 'dark' : 'light';
return choice;
}, [choice, system]);
useEffect(() => {
SystemUI.setBackgroundColorAsync(COLORS[theme].background);
}, [theme]);
const setChoice = (c: ThemeChoice) => {
storage.set(KEY, c);
setChoiceState(c);
};
return (
<ThemeContext.Provider value={{ choice, theme, setChoice }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Two details really matter here. The useState initializer reads MMKV synchronously, so the first render already has the correct choice baked in. And SystemUI.setBackgroundColorAsync paints the root native view — without it, navigating between screens that slide in from the side will briefly flash white through the gap. (Yes, I learned that the hard way on a client demo.)
Wiring NativeWind v4's dark variant
NativeWind v4 looks at a class name like dark:bg-slate-900 and resolves it through a global color scheme. To make it honor your in-app choice instead of only the OS choice, call colorScheme.set() whenever the theme changes:
// theme/ThemeProvider.tsx (additions)
import { colorScheme as nativewindScheme } from 'nativewind';
useEffect(() => {
nativewindScheme.set(choice); // 'system' | 'light' | 'dark'
}, [choice]);
Then configure tailwind.config.js with the darkMode strategy NativeWind expects:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
darkMode: 'class',
theme: { extend: {} },
plugins: [],
};
And now any component can write theme-aware styles inline:
<View className="flex-1 bg-white dark:bg-slate-900">
<Text className="text-slate-900 dark:text-slate-100">
Hello, world
</Text>
</View>
The status bar and the Android navigation bar
The status bar is the one piece almost every tutorial forgets. If your background flips to #0a0a0a but the status bar text stays dark, the time and battery indicator just… disappear into the void.
Use expo-status-bar and re-render it whenever the theme changes:
// app/_layout.tsx
import { StatusBar } from 'expo-status-bar';
import { ThemeProvider, useTheme } from '../theme/ThemeProvider';
import { Slot } from 'expo-router';
function StatusBarShim() {
const { theme } = useTheme();
return <StatusBar style={theme === 'dark' ? 'light' : 'dark'} />;
}
export default function RootLayout() {
return (
<ThemeProvider>
<StatusBarShim />
<Slot />
</ThemeProvider>
);
}
For Android's three-button navigation bar, set the color in app.json per variant, or call NavigationBar.setBackgroundColorAsync() from expo-navigation-bar inside the same effect that sets the system UI background.
Eliminating the splash-screen flash
Even with everything above wired up, you might still see a one-frame white flash on cold start. That's the splash image's background color, which gets baked into the native binary and can't change at runtime. The fix is to configure it per UI style in app.json:
{
"expo": {
"splash": {
"image": "./assets/splash.png",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/splash-dark.png",
"backgroundColor": "#0a0a0a"
}
},
"userInterfaceStyle": "automatic"
}
}
The userInterfaceStyle: "automatic" flag tells iOS and Android to pick the variant matching the system scheme. If your in-app override differs from the system, the splash will still match the system — but only for the few hundred milliseconds before your JS hydrates. That's short enough to be invisible, as long as you also call SystemUI.setBackgroundColorAsync the moment your provider mounts.
Letting users pick "System / Light / Dark"
A typical settings screen renders three radio buttons. With our provider, it's pretty much trivial:
import { Pressable, Text, View } from 'react-native';
import { useTheme } from '../theme/ThemeProvider';
const OPTIONS = ['system', 'light', 'dark'] as const;
export default function AppearanceScreen() {
const { choice, setChoice } = useTheme();
return (
<View className="p-4 gap-2 bg-white dark:bg-slate-900 flex-1">
{OPTIONS.map((opt) => (
<Pressable
key={opt}
onPress={() => setChoice(opt)}
className={`p-4 rounded-xl border ${
choice === opt
? 'border-blue-500 bg-blue-50 dark:bg-blue-950'
: 'border-slate-200 dark:border-slate-700'
}`}
>
<Text className="text-slate-900 dark:text-slate-100 capitalize">
{opt}
</Text>
</Pressable>
))}
</View>
);
}
React Navigation theme integration
If you use React Navigation (and that includes Expo Router, which is built on top of it), pass a theme object so headers, tab bars, and modal backdrops flip along with everything else:
import { ThemeProvider as NavThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
import { useTheme } from './theme/ThemeProvider';
function NavWrapper({ children }: { children: React.ReactNode }) {
const { theme } = useTheme();
return (
<NavThemeProvider value={theme === 'dark' ? DarkTheme : DefaultTheme}>
{children}
</NavThemeProvider>
);
}
Wrap your Slot or Stack with NavWrapper inside ThemeProvider. React Navigation will pick up the values, and you'll get correct colors on the navigation bar, header tint, and even the gesture-driven swipe-back overlay (which is the kind of small detail nobody notices until it's wrong).
Animating the transition
By default, color changes snap. If you want them to ease, use Reanimated's useDerivedValue with interpolateColor driven by a theme value:
import Animated, { useDerivedValue, interpolateColor, useAnimatedStyle, withTiming } from 'react-native-reanimated';
import { useTheme } from './theme/ThemeProvider';
export function AnimatedBackground({ children }) {
const { theme } = useTheme();
const progress = useDerivedValue(() => withTiming(theme === 'dark' ? 1 : 0, { duration: 220 }));
const style = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(progress.value, [0, 1], ['#ffffff', '#0a0a0a']),
flex: 1,
}));
return <Animated.View style={style}>{children}</Animated.View>;
}
Keep transitions under 250ms. Anything longer feels laggy on toggle, and you also end up with a visible mismatch between the JS-driven background and the native status bar (which always snaps instantly).
Testing on real devices
Three things only really show up on hardware:
- iOS Control Center quick toggle. On a physical iPhone you can switch system appearance straight from the lock screen — verify your app updates immediately when foregrounded.
- Android per-app overrides. Android 13+ supports per-app language and dynamic color, so the system scheme your app sees may differ from what other apps see.
- OLED black levels.
#0a0a0alooks black on an LCD but slightly grey on OLED Pixel and iPhone Pro screens. If you want true black for OLED battery savings, go with#000000— but raise card backgrounds to something like#0f172aso depth is still readable.
Common bugs and how to debug them
White flash on cold start. Almost always the splash background. Set splash.dark.backgroundColor in app.json and rebuild — Expo Go can't pick this up; you need a development build.
Theme resets on every app launch. You're using AsyncStorage (async) and rendering before the read resolves. Switch to MMKV's synchronous getString, or gate rendering on a hydrated flag.
Status bar invisible on Android. The default style is "auto", which mimics the background. Set it explicitly to "light" or "dark" based on theme.
Modals retain the old theme. React Native modals create a new native window, so wrap their content in your ThemeProvider again — or use react-native-screens' transparent presentation, which inherits the parent tree.
FAQ
Does useColorScheme work in Expo Go?
Yes — both useColorScheme and the Appearance module are part of React Native core and ship in Expo Go. That said, splash.dark and any custom config-plugin changes need a development build (run npx expo prebuild followed by npx expo run:ios or run:android).
Why does my dark mode flash white on app launch?
Three causes, in order of likelihood. First, your splash background color is white in app.json — add a dark variant. Second, you're reading the persisted theme from AsyncStorage, which resolves asynchronously, so the first render falls back to light. Third, you forgot to call SystemUI.setBackgroundColorAsync, so the native root view stays white during navigation transitions.
Should I use NativeWind or styled-components for dark mode?
For new projects in 2026, NativeWind v4 is honestly the lowest-friction choice — the dark: variant compiles down to native styles with no runtime cost, and theme switching is a single colorScheme.set() call. Styled-components still works but adds JS-thread overhead and needs a separate ThemeProvider. Unistyles 3 is another solid option if you want media queries plus dark mode in the same API.
How do I detect dark mode outside React (e.g., in a worklet or native module)?
Inside a Reanimated worklet, read from a SharedValue updated by your provider — you can't call hooks there. For native modules, expose the value through your provider's useEffect and forward it via a setter on the native side. The OS-level API is Appearance.getColorScheme() from react-native, which is safe to call anywhere on the JS thread.
Does the dark mode setting affect App Store review?
Indirectly — reviewers test with system dark mode enabled, and apps that show unreadable text or jarring white flashes on the splash tend to get flagged for "incomplete experience". Following the steps in this guide (splash variant, status bar tint, system UI background) is generally enough to pass without specific dark-mode design polish.
Wrapping up
A robust dark mode in React Native is less about picking colors and more about getting the JS layer, the native chrome, the splash image, and the persistence layer to all agree on a single source of truth. With useColorScheme for the OS signal, MMKV for the user's choice, expo-system-ui for the root view, expo-status-bar for the icons, and NativeWind v4 for the actual styles, you end up with a fast, flicker-free experience that matches what users have come to expect from native apps in 2026. Ship it.