DEV Community

Cover image for Light, dark, and system themes in Expo + NativeWind — the whole app, from one setting
Ibukun Demehin
Ibukun Demehin

Posted on

Light, dark, and system themes in Expo + NativeWind — the whole app, from one setting

Most "dark mode in React Native" tutorials stop at dark: classes and call it done. But a real setting needs three states — Light, Dark, and System — it has to survive an app restart, and the native chrome (tab bar, navigation header) has to follow along, not just your own views. Here's the full thing, and the nice surprise is how little wiring the last part takes.

TL;DR — Define your colors once as a palette with light + dark stops in tailwind.config, so every class is a pair (bg-page dark:bg-page-dark). Then a single call — NativeWind's colorScheme.set(pref) — applies the user's choice. It drives both the dark: variants and React Native's Appearance, so expo-router's theme and the native tab bar switch for free. Hold the choice in Redux, persist it to AsyncStorage, and restore it on launch.

(Part 6 of Building CannyCart, a voice-first shopping app I'm building in public. This one's a self-contained Expo how-to — no earlier context needed.)

The Shopping list in light mode — cards, mint category chips, ink text, and the tab bar

The same Shopping list in dark mode — surfaces, the brand teal, and the tab bar all swapped

Who this is for

You're on Expo + NativeWind and you already sprinkle dark: classes around. You want to turn that into an actual Appearance setting a user can pick — including a "System" option that follows the phone — and you want it to be remembered and to theme the whole app, tab bar included.

Prerequisites

  • Expo SDK 57, expo-router
  • NativeWind v3 (Tailwind v3 under the hood)
  • Redux Toolkit for the in-app choice
  • @react-native-async-storage/async-storage for persistence

The shape, before any steps

palette (one object)  → tailwind.config dark stops → bg-page dark:bg-page-dark
user picks a mode     → Redux prefsSlice.theme      (in-app source of truth)
                      → colorScheme.set(pref)        (drives NativeWind + RN Appearance)
                      → AsyncStorage                 (persist across launches)
on launch             → load pref → dispatch + apply (restore, no flash)
Enter fullscreen mode Exit fullscreen mode

Five moving parts, and exactly one of them (colorScheme.set) is doing the heavy lifting.

Step 1 — one palette, dark stops in Tailwind

Define colors once and give each a dark counterpart in tailwind.config, so a token becomes a pair. This keeps "what colour is a card" in a single place instead of scattered across components.

// tailwind.config.ts
import { palette } from "./src/constants/design-tokens";

export default {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  presets: [require("nativewind/preset")],
  theme: {
    extend: {
      colors: {
        page:    { DEFAULT: palette.neutral.background, dark: palette.dark.background },
        surface: { DEFAULT: palette.neutral.surface,    dark: palette.dark.surface },
        ink:     { DEFAULT: palette.neutral.textPrimary, inverse: palette.dark.textPrimary },
        brand:   { DEFAULT: palette.brand.primary, bright: palette.dark.primary /* teal on dark */ },
        // …line, coral, semantic trio, each with its dark stop
      },
    },
  },
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

Now every surface is a class pair. You write the light token and its dark stop together, and the dark: variant does the switch:

<View className="flex-1 bg-page p-5 dark:bg-page-dark">
  <Text className="text-ink dark:text-ink-inverse">Weekly shop</Text>
</View>
Enter fullscreen mode Exit fullscreen mode

One RN gotcha worth stealing: React Native can't synthesize font weights, so each weight is a separate loaded font file and its own family (font-nunito, font-nunito-bold, …) — not font-bold. Unrelated to theming, but it lives in the same config and bites everyone once.

Step 2 — the one call that themes everything

Here's the whole engine. NativeWind exposes an imperative colorScheme handle; setting it to "light", "dark", or "system" is the entire apply step:

// lib/theme-mode.ts
import { colorScheme } from "nativewind";

export function applyThemePref(pref: ThemePref): void {
  colorScheme.set(pref); // "light" | "dark" | "system"
}
Enter fullscreen mode Exit fullscreen mode

The reason this is the whole engine — and not just half of it — is what colorScheme.set touches. It drives NativeWind's dark: variants and React Native's Appearance API. So anything reading useColorScheme() updates too: expo-router's ThemeProvider, and with it your navigation header and the native tab bar. You theme your own views with dark: classes; the framework chrome comes along for free because it's reading the same signal.

// app/_layout.tsx — nav + tab bar follow useColorScheme(), which colorScheme.set() feeds
const colorScheme = useColorScheme();
// …
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
  <AuthGate><AppTabs /></AuthGate>
</ThemeProvider>
Enter fullscreen mode Exit fullscreen mode

That's the payoff of the whole post: you don't theme each surface separately. One call, and the utility classes, the nav theme, and the tab bar all move together. Here's a second, entirely different screen — the More tab — proving it: same setting, and the list rows, header, and tab bar all follow.

The More tab in light mode — the settings rows where the Appearance setting lives

The More tab in dark mode — list rows, header, and tab bar all following the same setting

Step 3 — a source of truth in Redux

colorScheme.set() applies a mode, but your UI still needs to know the current choice — to check the selected row in the picker, for instance. That's client state, so it goes in Redux (server data belongs to React Query — never mix the two):

// store/slices/prefsSlice.ts
export type ThemePref = "system" | "light" | "dark";

const initialState = { theme: "system" as ThemePref };

export const prefsSlice = createSlice({
  name: "prefs",
  initialState,
  reducers: {
    setThemePref: (state, action: PayloadAction<ThemePref>) => {
      state.theme = action.payload;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 4 — persist it to AsyncStorage

A setting that forgets itself on relaunch isn't a setting. Load and save the pref under one key, and keep both operations non-fatal — a storage hiccup should never crash the app or block the theme from applying for the current session:

// lib/theme-mode.ts
const THEME_KEY = "cannycart:themePref";

export async function loadThemePref(): Promise<ThemePref> {
  try {
    const stored = await AsyncStorage.getItem(THEME_KEY);
    if (stored === "light" || stored === "dark" || stored === "system") return stored;
  } catch {
    // fall through to default
  }
  return "system"; // sensible default: follow the phone
}

export async function saveThemePref(pref: ThemePref): Promise<void> {
  try {
    await AsyncStorage.setItem(THEME_KEY, pref);
  } catch {
    // non-fatal: the pref still applies for this session
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — the Appearance picker

Now the UI. Three rows; selecting one does three things in order — record it in Redux, apply it, and persist it (fire-and-forget, because the apply shouldn't wait on disk):

// app/more/appearance.tsx
function select(pref: ThemePref) {
  dispatch(setThemePref(pref)); // 1. source of truth
  applyThemePref(pref);         // 2. colorScheme.set — themes the whole app
  saveThemePref(pref);          // 3. persist, fire-and-forget
}
Enter fullscreen mode Exit fullscreen mode

The rows themselves are just class pairs, with the selected one tinted and checked:

<Pressable
  onPress={() => select(option.value)}
  className={`flex-row items-center gap-3 px-4 py-4
    ${selected ? "bg-brand-mint dark:bg-brand-mint-dark" : ""}`}
>
  <Text className="text-ink dark:text-ink-inverse">{option.label}</Text>
  {selected ? <Iconify icon="mdi:check-circle" size={22} color={brand} /> : null}
</Pressable>
Enter fullscreen mode Exit fullscreen mode

Step 6 — restore on launch, without a flash

Persistence is only half of "remembered." On the next launch you have to read the pref back and apply it before the app paints, or the user sees a flash of the wrong theme. A tiny component does it — it renders nothing, sits inside the Redux provider, and applies the stored pref on mount:

// app/_layout.tsx
function ThemeModeInit() {
  const dispatch = useAppDispatch();
  useEffect(() => {
    loadThemePref().then((pref) => {
      dispatch(setThemePref(pref)); // sync Redux
      applyThemePref(pref);         // and the actual theme
    });
  }, [dispatch]);
  return null;
}

// …mounted first thing inside <ReduxProvider>, above the rest of the tree:
<ReduxProvider store={store}>
  <ThemeModeInit />
  {/* QueryClient → SafeArea → ThemeProvider → tabs … */}
</ReduxProvider>
Enter fullscreen mode Exit fullscreen mode

The native splash is already held while fonts load (SplashScreen.preventAutoHideAsync()), which gives the async read a beat to resolve before the first real frame — so System-in-dark comes up dark, not a white flash then dark.

Verify it works

Four checks, in order:

  1. System follows the phone. Pick System, then flip your device to Dark in Control Center / Quick Settings — the app should switch live.
  2. Light/Dark override. Pick Dark on a light phone (and vice versa) — it should hold regardless of the device.
  3. It persists. Fully kill and relaunch — your last choice should come back with no flash.
  4. The chrome follows. In dark mode, confirm the tab bar and the navigation header are dark too — that's the colorScheme.setAppearance link doing its job. If your own views go dark but the tab bar stays light, you themed with dark: but never called colorScheme.set.

Where to go next

Two threads from here. First, per-user hygiene: this key is global, so on a shared device one account's theme would greet the next. Clearing user-specific AsyncStorage keys on sign-in/out (the user-prefs-cache-setup pattern) closes that. Second, the web app is still on light-only — the same palette already feeds its Tailwind config, so the port is mostly mode plumbing, not redesign.

Which surface in your app was the last to get the theming memo? For me it's always a hard-coded #fff on a shadow or a border, smug and glowing in dark mode. Tell me yours.

Top comments (0)