Dark mode in React Native is easy to start and surprisingly hard to keep clean as an app grows. The difference between a quick toggle and a durable theming system is structure: clear design tokens, a predictable theme layer, system appearance sync, and a repeatable way to style screens, forms, lists, and third-party components. This guide walks through a practical workflow for building React Native dark mode that stays maintainable whether you use Expo or the React Native CLI, write JavaScript or TypeScript, and rely on your own UI kit or external component libraries.
Overview
This tutorial shows how to build dark mode in React Native as a system rather than a patchwork of conditional colors. The goal is not just to switch backgrounds from white to black. It is to create a theming approach you can extend across navigation, forms, states, and reusable components without rewriting styles every time your design system changes.
A good React Native theming setup usually has four layers:
- Design tokens: raw values such as colors, spacing, radius, typography, and elevation.
- Semantic theme roles: names like
background,textPrimary,borderMuted, anddangerthat map tokens to UI meaning. - Theme state: a source of truth for light, dark, or system mode.
- Component usage: buttons, cards, inputs, tabs, and screens consuming semantic values instead of hardcoded colors.
If you only remember one principle, make it this: components should depend on semantic theme roles, not raw hex values. That single decision makes dark mode more consistent, easier to refactor, and less fragile when your palette evolves.
This workflow works well for:
- small apps that need a simple system theme toggle
- production apps with shared components and multiple contributors
- Expo projects that need fast iteration
- TypeScript codebases that benefit from typed tokens and theme objects
Step-by-step workflow
Use this process when implementing dark mode in a new app or refactoring an existing one.
1. Define the modes you actually support
Before writing code, decide whether your app supports:
- Light only
- Dark only
- Light and dark with manual toggle
- System sync with optional user override
For most apps, the most practical choice is system sync plus user override. That means the app follows the device appearance by default, but users can explicitly choose light or dark if they prefer.
This gives you a simple mental model:
themePreference = 'system' | 'light' | 'dark'resolvedTheme = 'light' | 'dark'based on the system appearance when preference issystem
That distinction matters because many teams accidentally store only the resolved value, which makes system syncing awkward later.
2. Start with design tokens, not component styles
Create a token file for raw visual values. Keep it small at first. You do not need a full enterprise design system to benefit from design tokens.
export const tokens = {
color: {
white: '#FFFFFF',
black: '#000000',
gray50: '#F7F7F8',
gray100: '#E9E9EC',
gray400: '#8E8E93',
gray800: '#1C1C1E',
gray900: '#111113',
blue500: '#3B82F6',
red500: '#EF4444',
yellow500: '#EAB308',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
radius: {
sm: 8,
md: 12,
lg: 16,
},
};These are not yet your light and dark themes. They are your raw materials.
3. Build semantic themes for light and dark
Next, map tokens to semantic roles. This is where dark mode becomes reusable.
import { tokens } from './tokens';
export const lightTheme = {
mode: 'light',
colors: {
background: tokens.color.white,
surface: tokens.color.gray50,
textPrimary: tokens.color.black,
textSecondary: tokens.color.gray400,
border: tokens.color.gray100,
primary: tokens.color.blue500,
danger: tokens.color.red500,
warning: tokens.color.yellow500,
},
};
export const darkTheme = {
mode: 'dark',
colors: {
background: tokens.color.gray900,
surface: tokens.color.gray800,
textPrimary: tokens.color.white,
textSecondary: tokens.color.gray400,
border: '#2C2C2E',
primary: '#60A5FA',
danger: '#F87171',
warning: '#FACC15',
},
};Notice that component names like button or card do not appear here. Theme roles should describe intent, not implementation. A card, modal, list row, and bottom sheet might all use surface.
4. Add a theme provider
Create a provider that reads the system appearance, stores the user preference, and exposes the resolved theme through context.
At a high level, your provider should handle:
- reading the device color scheme with
useColorScheme - resolving
systemto light or dark - optionally loading and saving the user preference from storage
- exposing the final theme object and a setter
type ThemePreference = 'system' | 'light' | 'dark';
type ThemeContextValue = {
preference: ThemePreference;
resolvedTheme: 'light' | 'dark';
theme: typeof lightTheme;
setPreference: (value: ThemePreference) => void;
};If you are already using app-wide state, keep theme state lightweight. It does not always need Redux or another external store. Context is often enough. If your app already has a broader architecture decision around state, see Best React Native State Management in 2026: Redux Toolkit, Zustand, Jotai, and Context for tradeoffs.
5. Create a useTheme hook
Do not import theme objects directly into feature components. Wrap access in a hook so the rest of the app stays decoupled from how theme state is managed.
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}This gives every component a clean interface:
const { theme, resolvedTheme } = useTheme();6. Refactor base layout components first
When adding React Native dark mode to an existing codebase, do not start with every screen. Start with the shared pieces that influence the whole app:
- App container or screen wrapper
- Text component
- Button component
- Input component
- Card or surface component
- Divider and icon wrappers
This gives you leverage. Once shared primitives consume semantic colors, many screens inherit dark mode support with minimal extra work.
For example, a themed screen wrapper might use:
backgroundfor the root containertextPrimaryfor titlestextSecondaryfor captionsborderfor separators
This is also a good time to remove hardcoded color values from feature code. Search for hex colors, rgba values, and inline styles. Replace them with semantic roles wherever possible.
7. Integrate navigation themeing
Navigation is one of the easiest places to break dark mode consistency. Whether you use React Navigation directly or through Expo Router, make sure headers, tab bars, and backgrounds receive a matching theme object.
Your navigation layer should be aligned with your app theme in three places:
- screen background colors
- header and tab bar styling
- status bar content style
If you are working through a broader app setup decision, Expo vs React Native CLI: Which Setup Makes Sense in 2026? can help frame tooling choices, while your routing layer should still follow the same semantic theme model.
8. Handle images, illustrations, and elevation carefully
Dark mode is not only about text and backgrounds. A polished result usually requires adjustments for:
- logos and illustrations: assets designed for light backgrounds may disappear in dark mode
- borders and dividers: subtle light-mode borders can look too heavy or too faint in dark mode
- shadows and elevation: shadows behave differently on dark surfaces and often need reduced opacity or alternative treatment
- overlays: modals, bottom sheets, and pressed states often need separate opacity tuning
If your app uses patterns like sheets or layered surfaces, semantic roles such as surface, surfaceElevated, and overlay are often more durable than one generic background color.
9. Theme forms, lists, and empty states
Real apps tend to break in common UI states rather than on the main happy path. Make sure your React Native dark mode system accounts for:
- input text, labels, placeholders, helper text, and errors
- checkbox, switch, and radio selected states
- list separators, pressed backgrounds, and sticky headers
- loading skeletons and activity indicators
- empty states and offline states
- toast, alert, and banner colors
If your app has complex forms, this is a good companion topic to React Native Forms Compared: React Hook Form, Formik, and Zod Validation Patterns. Validation colors and disabled states should be checked in both themes, not added as an afterthought.
For long lists, keep theme-aware row styles lightweight to avoid unnecessary rerenders. If list performance matters, review React Native FlatList and FlashList Benchmarks: When to Use Each as you scale themed item rendering.
10. Persist user preference only after the core system works
Storage is useful, but it should not be the first step. Get the live theme resolution working first, then persist the user choice with your preferred storage layer.
The sequence should be:
- read system appearance
- apply light or dark theme
- add a manual preference control
- persist the preference
- restore it during app startup
This order makes debugging easier and reduces confusion around whether a visual issue is caused by storage, sync logic, or the theme definitions themselves.
Tools and handoffs
Theming touches design, engineering, QA, and often product. A few simple handoffs make the work smoother.
From design to code
Ask for semantic roles, not just dark mockups. Instead of receiving only two screens, try to get a reusable token map covering:
- backgrounds and surfaces
- text hierarchy
- borders and dividers
- interactive states
- success, warning, and error colors
- disabled and placeholder states
If design cannot provide a full token system yet, engineers can still create one from existing screens. The important part is naming values by purpose.
UI libraries and third-party components
If you use a component library, decide early whether you will:
- adopt the library's theme system as your source of truth
- map your own theme into the library
- wrap library components behind your own design system primitives
The third option often scales best in product apps because it limits how much library-specific logic leaks into feature code.
For native integrations like maps, cameras, or authentication flows, expect some theme edges to need separate handling. Full-screen native surfaces do not always inherit your React theme automatically. Related implementation details often show up in guides like React Native Maps Guide: Google Maps, Apple Maps, Clustering, and Performance Tips, React Native Camera Libraries Compared: Expo Camera, VisionCamera, and Native Options, and How to Add Authentication to React Native: Email, OAuth, Magic Links, and Passkeys.
TypeScript handoff
TypeScript is especially useful for theming because it prevents missing theme keys and inconsistent naming. A typed theme object helps you refactor safely when tokens change. If you introduce roles like surfaceElevated or textTertiary, TypeScript makes it easier to find components that still depend on older names.
Testing handoff
QA should not have to discover every dark mode defect manually. Add predictable test cases for both appearances, especially for navigation, forms, and state-heavy screens. If you are building out a broader test process, React Native Testing Strategy: Unit, Integration, and E2E Tools Compared is a useful companion.
Quality checks
A dark mode launch is usually ready when the app is consistent in both common flows and edge states. Use this checklist before calling the work done.
Visual consistency
- All major screens have the correct root background.
- Text remains readable across headings, body copy, captions, and metadata.
- Cards, modals, sheets, and overlays use distinct but related surfaces.
- Borders and dividers are visible without dominating the UI.
- Icons and illustrations remain legible in both themes.
Interaction states
- Pressed, focused, disabled, and selected states are visible.
- Error, warning, and success feedback remain clear in dark mode.
- Switches, checkboxes, and segmented controls retain contrast.
- Loading indicators and skeletons look intentional, not washed out.
System integration
- The app responds correctly when the device theme changes.
- User override works and persists after restart.
- Status bar content matches the active theme.
- Navigation headers and tab bars stay aligned with the app theme.
Performance and debugging
Dark mode should not cause noticeable flicker or full-screen style churn. If you see unnecessary rerenders, check whether your theme object is recreated too often or whether large lists are receiving unstable style props.
Common issues include:
- inline style objects depending on theme in every render
- large trees rerendering because provider values are not memoized
- third-party components caching styles in unexpected ways
- a flash of wrong theme during app startup before stored preference loads
If debugging becomes messy, a structured workflow helps. See How to Debug React Native Apps: A Tool-by-Tool Guide for Logs, Network, Crashes, and Native Errors for a broader troubleshooting approach.
Accessibility
Dark mode should improve comfort, not reduce usability. Check:
- contrast between text and background
- placeholder text that is still readable
- focus states that are visible on dark surfaces
- color meaning that is not the only signal for errors or status
A useful rule is to test your most information-dense screens first. Dashboards, settings screens, and forms expose contrast problems faster than simple landing screens.
When to revisit
Dark mode is not a one-time task. It should be revisited whenever your app's design system, navigation structure, or platform behavior changes.
Plan to review your theming system when:
- you add a new component library or replace an existing one
- your design team introduces new semantic roles or rebrands the palette
- you add major surfaces such as maps, cameras, chat, charts, or bottom sheets
- you change navigation patterns or screen containers
- you notice visual drift from teams adding raw colors directly in feature code
- platform APIs around appearance or system UI change
A simple maintenance routine works well:
- Audit hardcoded colors every release cycle or sprint.
- Promote repeated values into tokens or semantic roles.
- Review one critical user flow in both light and dark mode.
- Test startup, system sync, and stored preference behavior after dependency updates.
- Document new theme roles as soon as they are introduced.
If you want a practical next step, do this today:
- create a
tokens.tsfile - define
lightThemeanddarkTheme - add a
ThemeProviderwithsystem | light | dark - refactor your screen wrapper, text, button, and input components first
- connect navigation and status bar styling
- run a two-theme QA pass on forms, lists, and empty states
That sequence is enough to turn React Native dark mode from a visual toggle into a maintainable UI foundation. Once your primitives and semantic roles are in place, future updates become much easier, whether you are expanding a design system, adding new features, or adapting to changes in the React Native ecosystem.