By the end of this lesson you will:
- Understand what “theming” means in a mobile application
- Build a reusable theme system using theme tokens
- Use a style factory function to keep JSX clean
- Introduce ThemeContext and use
useContext()to read theme values in any screen - Connect theme values to React Navigation UI
- (Bonus) Detect system dark mode with
useColorScheme()
You can choose to apply the ideas here to one of your existing projects, or scaffold a project using the starter code provided below.
If you are using one of your own projects, skip to [Part 1 — Why Theming Matters] below.
You will build a tiny app first. It is intentionally ugly and hardcoded so you can practice theming.
A 2-tab app:
- Home tab: headings, text, and a couple buttons
- List tab: a list with a simple “row” layout (title on left, rating on right)
You will refactor the hardcoded colors/spacings into theme tokens.
If you don’t already have navigation installed:
npm install @react-navigation/native
npx expo install react-native-screens react-native-safe-area-context
npm install @react-navigation/bottom-tabsCreate these files:
.
├── App.js
└── screens
├── HomeScreen.js
└── ListScreen.js
Paste this:
import * as React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Ionicons } from '@expo/vector-icons';
import HomeScreen from './screens/HomeScreen';
import ListScreen from './screens/ListScreen';
const Tab = createBottomTabNavigator();
export default function App() {
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={({ route }) => ({
headerTitleAlign: 'center',
tabBarIcon: ({ focused, color, size }) => {
const icons = {
Home: focused ? 'home' : 'home-outline',
List: focused ? 'list' : 'list-outline',
};
return <Ionicons name={icons[route.name]} size={size} color={color} />;
},
tabBarActiveTintColor: 'tomato',
tabBarInactiveTintColor: 'gray',
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="List" component={ListScreen} />
</Tab.Navigator>
</NavigationContainer>
);
}Paste this:
import * as React from 'react';
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
export default function HomeScreen() {
return (
<View style={styles.container}>
<Text style={styles.h1}>Theme Playground</Text>
<Text style={styles.p}>
This screen is intentionally hardcoded. Your job is to remove the hardcoded values
and replace them with theme tokens.
</Text>
<View style={styles.card}>
<Text style={styles.h2}>Card Title</Text>
<Text style={styles.p}>
A “card” is a common UI pattern. In a real app you will reuse this style many times.
</Text>
<View style={styles.row}>
<Pressable style={styles.primaryButton} onPress={() => Alert.alert('Primary')}>
<Text style={styles.primaryButtonText}>Primary</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={() => Alert.alert('Secondary')}>
<Text style={styles.secondaryButtonText}>Secondary</Text>
</Pressable>
</View>
</View>
<Text style={styles.small}>
Goal: When your theme toggles, this entire screen should change correctly.
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#ffffff',
},
h1: { fontSize: 30, fontWeight: '800', color: '#111111', marginBottom: 10 },
h2: { fontSize: 20, fontWeight: '700', color: '#111111', marginBottom: 6 },
p: { fontSize: 16, color: '#333333', lineHeight: 22, marginBottom: 10 },
small: { marginTop: 16, color: '#555555' },
card: {
padding: 16,
borderRadius: 12,
backgroundColor: '#f4f4f4',
borderWidth: 1,
borderColor: '#dddddd',
},
row: { flexDirection: 'row', gap: 10 },
primaryButton: {
backgroundColor: '#f4511e',
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 10,
},
primaryButtonText: { color: 'white', fontWeight: '700' },
secondaryButton: {
backgroundColor: '#222222',
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 10,
},
secondaryButtonText: { color: 'white', fontWeight: '700' },
});Paste this:
import * as React from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
const DATA = [
{ name: 'Malamute', rating: 5 },
{ name: 'Shiba', rating: 4 },
{ name: 'Corgi', rating: 5 },
{ name: 'Greyhound', rating: 3 },
{ name: 'Pug', rating: 2 },
{ name: 'Husky', rating: 4 },
];
export default function ListScreen() {
return (
<View style={styles.container}>
<Text style={styles.h1}>Breed Ratings</Text>
<FlatList
data={DATA}
keyExtractor={(item) => item.name}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.rating}>{'★'.repeat(item.rating)}</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={styles.sep} />}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, backgroundColor: '#ffffff' },
h1: { fontSize: 26, fontWeight: '800', color: '#111111', marginBottom: 12 },
item: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 10,
backgroundColor: '#f4f4f4',
borderWidth: 1,
borderColor: '#dddddd',
},
name: { fontSize: 18, fontWeight: '600', color: '#111111' },
rating: { fontSize: 18, color: '#f4511e' },
sep: { height: 10 },
});- App runs
- Tabs work
- Home shows a card and buttons
- List shows rows with rating aligned right
If any of these fail, fix them before starting Part 1.
Open the sample app you just built. Notice how many values are hardcoded and repeated across screens.
Ask yourself:
- What happens if we want dark mode?
- What happens if the brand color changes?
- What happens if 20 components use the same hardcoded value?
Theming is about:
- Centralizing design decisions
- Making dark mode possible
- Making apps scalable
- Separating design from components
You are now going to refactor the sample app scaffold you built earlier (Home + List tabs).
This app is intentionally:
- Hardcoded
- Repetitive
- Not ready for dark mode
Your job is to fix that.
You are not building something new — you are improving what already exists.
Open:
HomeScreen.jsListScreen.js
Scan the styles.
Make a quick list (in comments or on paper):
- What colors repeat across both screens?
- What padding numbers repeat?
- Where do you see the same borderRadius value?
- Where do you see the same background color used multiple times?
Now discuss:
If you had to support dark mode right now, how many lines would you have to change?
This is the problem theming solves.
Create a file called theme.js at the root of your project.
Before writing values, decide on structure.
What categories does your app need?
Common categories:
- colors
- spacing
- radius
- typography (optional)
// theme.js
export const lightTheme = {
colors: {
// background
// text
// primary
// secondary
// card
// border
},
spacing: {
// sm
// md
// lg
},
radius: {
// sm
// md
}
};
export const darkTheme = {
// Mirror the exact same structure as lightTheme
};Important:
- Light and dark themes must have the exact same structure.
- Every repeated value in your demo app should become a token.
- Do not invent tokens you don’t use.
Instead of writing large inline style objects inside JSX, build your styles from the theme using a factory function.
// styles.js (or inside HomeScreen.js)
import { StyleSheet } from 'react-native';
export const createStyles = (theme) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background,
padding: theme.spacing.md,
},
card: {
backgroundColor: theme.colors.card,
borderRadius: theme.radius.md,
padding: theme.spacing.md,
},
text: {
color: theme.colors.text,
},
button: {
backgroundColor: theme.colors.primary,
padding: theme.spacing.sm,
borderRadius: theme.radius.sm,
}
});Then in HomeScreen.js:
import { lightTheme } from '../theme';
import { createStyles } from './styles';
const theme = lightTheme;
const styles = createStyles(theme);This keeps your JSX clean and keeps all visual decisions centralized.
Now refactor ListScreen.js.
Your goal:
- Make list items use the same
cardstyling tokens as the Home screen. - Make text colors consistent with your theme.
- Remove ALL repeated style literals.
You should start noticing that:
Multiple screens are now sharing design decisions.
Discuss:
- What improved?
- What still feels repetitive?
- Which tokens were most useful?
- What would happen if we changed one color in
theme.js?
At this point, your theme file should be controlling the visual identity of your demo app.
That is the foundation of scalable design.
So far, you imported lightTheme directly.
That works — but it doesn’t scale.
What if:
- You want to toggle dark mode?
- You want different themes per user?
- You want to avoid importing the theme in every file?
This is where React Context helps.
useContext is a React hook that allows components to access shared values without passing props through multiple levels of the component tree.
Normally in React, data flows like this:
Parent → Child → Grandchild → Great‑grandchild
If many components need the same value (like a theme), you would have to “prop drill” that value through every level.
Context solves this by creating a shared channel.
When should you use Context?
Use Context for:
- Theme or design system values
- Authentication state
- Global user preferences
- Locale / language
- App‑wide configuration
These are values that:
- Change rarely
- Are needed in many places
- Represent global app state
When should you NOT use Context?
Do NOT use Context for:
- Frequently changing UI state (like text input values)
- Local component state
- Highly dynamic data lists
- Large, complex async state (Redux is better for that)
Context is best for stable, global values — not as a replacement for Redux.
In this lesson, we are using Context specifically for theming, which is exactly the kind of problem Context was designed to solve.
Create a new file: ThemeContext.js
import React from 'react';
import { lightTheme } from './theme';
export const ThemeContext = React.createContext(lightTheme);In App.js:
import { ThemeContext } from './ThemeContext';
import { lightTheme } from './theme';
<ThemeContext.Provider value={lightTheme}>
<YourNavigation />
</ThemeContext.Provider>Earlier in this lesson, you did this:
import { lightTheme } from '../theme';
const theme = lightTheme;
const styles = createStyles(theme);That tightly couples your screen to a specific theme file.
Now we replace that pattern with Context.
import React from 'react';
import { View, Text } from 'react-native';
import { ThemeContext } from '../ThemeContext';
import { createStyles } from './styles';
export default function HomeScreen() {
const theme = React.useContext(ThemeContext);
const styles = createStyles(theme);
return (
<View style={styles.container}>
<Text style={styles.text}>Home Screen</Text>
</View>
);
}Now the screen does NOT import lightTheme or darkTheme. It simply reads whatever theme the Provider supplies.
When the Provider changes, the entire UI updates automatically.
With Context:
- You remove direct imports of theme files.
- You centralize theme decisions.
- You prepare for dark mode toggling.
- You make your app scalable.
This is how professional apps implement theming systems.
Add a state toggle in App.js:
const [mode, setMode] = useState('light');
const theme = mode === 'light' ? lightTheme : darkTheme;Pass theme into the Provider and add a button to toggle modes.
Watch your entire app change.
That is the power of a theme system.
React Navigation has its own theme shape. You can map your theme tokens into NavigationContainer so headers and tab bars match.
In your navigation root (where NavigationContainer lives):
import { NavigationContainer } from '@react-navigation/native';
<NavigationContainer
theme={{
dark: false, // update later
colors: {
primary: theme.colors.primary,
background: theme.colors.background,
card: theme.colors.card,
text: theme.colors.text,
border: theme.colors.border ?? '#ccc',
notification: theme.colors.primary,
},
}}
>
<TabNavigator />
</NavigationContainer>Activity:
- Make the header and tab bar match your theme.
- Confirm text remains readable in both modes.
Refactor to reduce duplication:
- Remove ALL hardcoded colors.
- Replace padding values with spacing tokens.
- Add one extra token group (typography OR shadow OR border widths).
- Make the List rows reuse the same “card” styles as the Home screen (same border radius, border color, and background).
- What got easier after tokens?
- What got easier after Context?
- What UI broke in dark mode, and why?
- Where should theme state live?
Manual toggles are useful for learning, but real apps often follow the system setting.
React Native includes a built‑in hook:
import { useColorScheme } from 'react-native';It returns 'light', 'dark', or null, and updates automatically when the system theme changes.
Instead of keeping a mode state, you can do:
import React from 'react';
import { useColorScheme } from 'react-native';
import { ThemeContext } from './ThemeContext';
import { lightTheme, darkTheme } from './theme';
export default function App() {
const scheme = useColorScheme();
const theme = scheme === 'dark' ? darkTheme : lightTheme;
return (
<ThemeContext.Provider value={theme}>
<YourNavigation />
</ThemeContext.Provider>
);
}Now your entire app automatically respects system dark mode.
- Open the Simulator
- Top menu: Features → Appearance → Dark
- Your app should update immediately
If it doesn’t:
- reload the app, or
- restart Expo with
npx expo start -c
Support BOTH:
- system theme (default)
- manual override stored in state (and optionally persisted)
Logic:
- if the user chose a theme, use it
- otherwise fall back to
useColorScheme()