Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 9 min read

React Native Dark Mode: The Complete 2026 Setup

Most dark mode tutorials stop at useColorScheme(), which is about 20% of the job. The other 80% is what breaks in review: the status bar stays black on black, the navigator flashes white between screens, one hardcoded #fff in a card component blinds the user at 11pm, and there’s no way to force light mode for the people who want it. This is the full setup — system detection, semantic tokens, a manual override, and the pieces everyone forgets.

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.

TokenLightDarkUsed for
background#FFFFFF#121212Screen background
surface#F5F5F5#1E1E1ECards, sheets, inputs
surfaceRaised#FFFFFF#252525Modals, popovers (lighter = closer)
textrgba(0,0,0,0.87)rgba(255,255,255,0.87)Primary copy
textMutedrgba(0,0,0,0.55)rgba(255,255,255,0.55)Captions, meta
borderrgba(0,0,0,0.10)rgba(255,255,255,0.10)Dividers, outlines
accent#EA580C#FB923CButtons, links — lighter in dark
danger#DC2626#F87171Destructive 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>
  );
}
  1. Navigator background. React Navigation paints its own background under your screens. Unthemed, you get a white strobe on every push in dark mode.
  2. Status bar. Dark text on a dark bar is invisible. Drive expo-status-bar from the resolved theme.
  3. The native splash screen. It has its own background color in app.json, and Expo supports a dark variant. A white splash into a dark app looks like a crash.
  4. 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.

Frequently Asked Questions

How do I detect dark mode in React Native?

Call useColorScheme() from react-native. It returns 'light', 'dark', or null and re-renders your component when the OS setting changes. On iOS you must also set userInterfaceStyle to 'automatic' in app.json, or the hook will report 'light' forever.

Why does useColorScheme always return light on iOS?

Because Expo defaults userInterfaceStyle to 'light', which locks the app to light appearance regardless of the system setting. Set "userInterfaceStyle": "automatic" in app.json (and inside the ios block), then rebuild — this is a native config change, so a JS reload will not pick it up.

Should I use useColorScheme or a theme context?

Both. useColorScheme gives you the OS preference; a context on top of it gives you the user override (Light / Dark / System) that reviewers expect in Settings. Read the hook, merge it with a stored preference, and expose the resolved theme through the context.

Does NativeWind support dark mode in React Native?

Yes. Set darkMode: 'class' in tailwind.config.js and use dark: variants exactly like on the web. NativeWind reads the color scheme itself, and its useColorScheme() hook exposes setColorScheme() so a manual toggle drives every dark: class in the app at once.

How do I make the status bar match dark mode?

Render <StatusBar style={theme === 'dark' ? 'light' : 'dark'} /> from expo-status-bar inside your themed provider so the bar text flips with the theme. Also set the navigation container theme, or you will get a white flash between screens in dark mode.

Should dark mode be pure black?

No. Pure #000 with pure #FFF text is the most common mistake — it causes halation and looks cheap on OLED. Use a near-black surface around #121212–#1C1C1C and text around 87% white opacity, which is what both Material and Apple's own dark palettes do.

→

React Native Performance: 10 Fixes

The re-render and list problems that show up right after theming does.

Read guide →
→

Expo EAS Submission Checklist

Everything to verify before review — including appearance settings.

See checklist →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.