Build a Practical Light and Dark Theme for Expo React Native
Create a reusable theme system for an Expo app with typed tokens, system light and dark mode, themed components, and Expo Router navigation colors.
The short version
Set Expo to follow the system theme, read the active scheme with useColorScheme, keep colors and spacing in one typed theme object, then consume that theme through small reusable components.
A good Expo theme is not only a light palette and a dark palette. It is a contract for how your app uses color, spacing, radius, typography, navigation, and system UI. Once that contract exists, screens stop hard-coding random hex values and your app becomes much easier to keep consistent.
1. Let Expo follow the system theme
Start in your app config. For iOS and Android, Expo uses userInterfaceStyle to decide whether the app follows light mode, dark mode, or the user's system preference. The practical default is automatic.
{
"expo": {
"name": "theme-demo",
"slug": "theme-demo",
"userInterfaceStyle": "automatic"
}
}
You can also set it per platform if your app needs different behavior, but keep the shared value first unless you have a real product reason to diverge.
{
"expo": {
"userInterfaceStyle": "automatic",
"ios": {
"userInterfaceStyle": "automatic"
},
"android": {
"userInterfaceStyle": "automatic"
}
}
}
Android note
If older Android theme switching does not behave as expected, install expo-system-ui and rebuild. Newer Expo templates usually make the basic light and dark flow feel straightforward, but native configuration still matters.
2. Read the active color scheme
React Native's useColorScheme hook tells your app whether the current appearance is light or dark. It also subscribes to changes, so the UI can update when the user changes their system setting.
import { useColorScheme } from "react-native";
export function usePreferredThemeName() {
const colorScheme = useColorScheme();
return colorScheme === "dark" ? "dark" : "light";
}
That fallback to light keeps the app predictable if the native module returns null. From here, the rest of the app can think in theme names instead of native appearance values.
3. Create typed theme tokens
Put your tokens in one file. The exact colors are yours, but name the roles by what the color does: background, surface, text, muted text, border, primary, and danger. That naming survives redesigns better than names like blue or gray.
// theme.ts
export const theme = {
light: {
colors: {
background: "#f8fafc",
surface: "#ffffff",
surfaceMuted: "#f1f5f9",
text: "#0f172a",
textMuted: "#64748b",
border: "#e2e8f0",
primary: "#0f766e",
primaryText: "#ffffff",
danger: "#dc2626",
},
},
dark: {
colors: {
background: "#0b1020",
surface: "#111827",
surfaceMuted: "#1f2937",
text: "#f8fafc",
textMuted: "#94a3b8",
border: "#334155",
primary: "#5eead4",
primaryText: "#082f2e",
danger: "#f87171",
},
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
radius: {
sm: 6,
md: 8,
lg: 12,
},
typography: {
body: 16,
title: 28,
small: 14,
},
} as const;
export type ThemeName = keyof Pick<typeof theme, "light" | "dark">;
export type AppTheme = typeof theme[ThemeName] & {
spacing: typeof theme.spacing;
radius: typeof theme.radius;
typography: typeof theme.typography;
};
The shared spacing, radius, and typography tokens stay outside light and dark mode because those values usually do not change with appearance. Colors do change, so they live inside each theme.
4. Add a ThemeProvider
A provider gives every screen the same theme object. It also gives you one place to add app-level overrides later, such as a settings screen that lets users force light mode, dark mode, or system mode.
// ThemeProvider.tsx
import { createContext, useContext, useMemo, type PropsWithChildren } from "react";
import { useColorScheme } from "react-native";
import { type AppTheme, type ThemeName, theme } from "./theme";
type ThemeContextValue = {
name: ThemeName;
theme: AppTheme;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: PropsWithChildren) {
const colorScheme = useColorScheme();
const name: ThemeName = colorScheme === "dark" ? "dark" : "light";
const value = useMemo(() => {
return {
name,
theme: {
...theme[name],
spacing: theme.spacing,
radius: theme.radius,
typography: theme.typography,
},
};
}, [name]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useAppTheme() {
const value = useContext(ThemeContext);
if (!value) {
throw new Error("useAppTheme must be used inside ThemeProvider.");
}
return value;
}
That hook makes consuming the theme boring in the best way. Any component can ask for theme.colors.surface or theme.spacing.md without needing to know where the current mode came from.
5. Build themed components
Start with three small components: text, view, and button. They keep the theme close to the UI while avoiding a custom styling system that is bigger than the app.
// Themed.tsx
import type { PropsWithChildren } from "react";
import {
Pressable,
Text,
View,
type PressableProps,
type StyleProp,
type TextProps,
type TextStyle,
type ViewProps,
type ViewStyle,
} from "react-native";
import { useAppTheme } from "./ThemeProvider";
type ThemedTextProps = TextProps & {
muted?: boolean;
title?: boolean;
};
export function ThemedText({
muted,
title,
style,
...props
}: ThemedTextProps) {
const { theme } = useAppTheme();
return (
<Text
{...props}
style={[
{
color: muted ? theme.colors.textMuted : theme.colors.text,
fontSize: title ? theme.typography.title : theme.typography.body,
fontWeight: title ? "700" : "400",
},
style,
]}
/>
);
}
export function ThemedView({
style,
...props
}: ViewProps & { style?: StyleProp<ViewStyle> }) {
const { theme } = useAppTheme();
return (
<View
{...props}
style={[
{ backgroundColor: theme.colors.background },
style,
]}
/>
);
}
type ThemedButtonProps = PressableProps &
PropsWithChildren<{
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}>;
export function ThemedButton({
children,
style,
textStyle,
...props
}: ThemedButtonProps) {
const { theme } = useAppTheme();
return (
<Pressable
{...props}
style={({ pressed }) => [
{
minHeight: 48,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.radius.md,
paddingHorizontal: theme.spacing.md,
backgroundColor: theme.colors.primary,
opacity: pressed ? 0.82 : 1,
},
style,
]}>
<Text
style={[
{
color: theme.colors.primaryText,
fontWeight: "700",
fontSize: theme.typography.body,
},
textStyle,
]}>
{children}
</Text>
</Pressable>
);
}
Keep these components boring at first. The real win is not cleverness; it is that common UI stops repeating the same colors, spacing, and font weights across every screen.
6. Wire the theme into Expo Router
Wrap your root layout with ThemeProvider, then read theme values inside the nested stack layout. That keeps the provider above the routes and lets navigation headers follow the same color system.
// app/_layout.tsx
import { Stack } from "expo-router";
import { ThemeProvider, useAppTheme } from "../theme/ThemeProvider";
function RootStack() {
const { theme } = useAppTheme();
return (
<Stack
screenOptions={{
contentStyle: {
backgroundColor: theme.colors.background,
},
headerStyle: {
backgroundColor: theme.colors.surface,
},
headerTintColor: theme.colors.text,
headerShadowVisible: false,
}}
/>
);
}
export default function RootLayout() {
return (
<ThemeProvider>
<RootStack />
</ThemeProvider>
);
}
If you use tabs, apply the same idea to Tabs: set the tab bar background, active tint, inactive tint, border, and scene background from the theme.
7. Use it in a real screen
Now screens can focus on product layout instead of theme plumbing. Here is a small settings-style screen that uses the theme tokens and components together.
// app/index.tsx
import { StyleSheet, View } from "react-native";
import { ThemedButton, ThemedText, ThemedView } from "../theme/Themed";
import { useAppTheme } from "../theme/ThemeProvider";
export default function HomeScreen() {
const { name, theme } = useAppTheme();
return (
<ThemedView style={styles.screen}>
<View
style={[
styles.card,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
borderRadius: theme.radius.lg,
padding: theme.spacing.lg,
},
]}>
<ThemedText title>Theme demo</ThemedText>
<ThemedText muted style={{ marginTop: theme.spacing.sm }}>
The app is currently using the {name} theme.
</ThemedText>
<ThemedButton style={{ marginTop: theme.spacing.lg }}>
Continue
</ThemedButton>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
justifyContent: "center",
padding: 20,
},
card: {
borderWidth: 1,
},
});
The card still uses ordinary React Native styles. The difference is that the values come from a theme contract instead of one-off magic numbers.
8. Add native color polish when you need it
Expo Router also exposes a typed Color API for platform colors. Use it when you want iOS system colors or Android Material colors, especially for surfaces that should feel closer to the host platform.
import { Color } from "expo-router";
import { Platform, useColorScheme } from "react-native";
export function usePlatformSurfaceColor() {
useColorScheme();
return Platform.select({
ios: Color.ios.systemBackground,
android: Color.android.dynamic.surface,
default: "#0b1020",
});
}
The important detail is the useColorScheme() call. On Android, it helps components re-render when Material colors respond to a system theme change.
Final checklist
- Set
userInterfaceStyletoautomaticin your Expo app config. - Use
useColorSchemeat the app boundary and normalize unknown values to light or dark. - Name color tokens by purpose, not by color family.
- Keep spacing, radius, and typography tokens shared unless the mode truly changes them.
- Wire Expo Router headers, tabs, and content backgrounds to the same theme.
- Test light mode, dark mode, system switching, web, and real devices.
- Check contrast for primary buttons, muted text, borders, and disabled states before shipping.