Module 4: StyleSheet Deep Dive

Theming and Dark Mode

Create a flexible design system with light and dark mode support

Table of Contents

🎯 Learning Objectives

  • Detect and respond to system color scheme preferences
  • Build a comprehensive theme structure with colors, spacing, and typography
  • Create a ThemeContext for app-wide theme access
  • Build themed components that adapt automatically
  • Implement manual theme switching (light/dark/system)
  • Persist user theme preferences with AsyncStorage
  • Handle status bar and navigation theming

Introduction

Dark mode isn't just a trend — it's an accessibility feature, a battery saver on OLED screens, and a user preference that modern apps must support. A well-implemented theme system makes your app adaptable, maintainable, and professional.

In this lesson, we'll build a complete theming system from scratch. By the end, you'll have reusable code that you can drop into any React Native project.

🌓 Why Theme Support Matters

  • User preference: Many users prefer dark mode, especially at night
  • Accessibility: Some users need high contrast or specific color schemes
  • Battery life: Dark mode saves battery on OLED/AMOLED screens
  • Platform expectations: iOS and Android both support system-wide dark mode

What We'll Build

Light Mode Settings 🌙 Dark Mode 🔔 Notifications 👤 Account Save Theme Switch Dark Mode Settings 🌙 Dark Mode 🔔 Notifications 👤 Account Save
flowchart TB
    subgraph System["System Detection"]
        CS["useColorScheme()"]
    end
    
    subgraph Theme["Theme System"]
        TC["ThemeContext"]
        LT["Light Theme"]
        DT["Dark Theme"]
    end
    
    subgraph Components["Themed Components"]
        TC --> C1["Cards"]
        TC --> C2["Buttons"]
        TC --> C3["Text"]
        TC --> C4["Inputs"]
    end
    
    CS --> TC
    TC --> LT
    TC --> DT
    
    style System fill:#e3f2fd
    style Theme fill:#fff3e0
    style Components fill:#e8f5e9
                

Detecting System Color Scheme

React Native provides the useColorScheme hook to detect the system's current color scheme preference.

Basic Usage

import { useColorScheme, View, Text, StyleSheet } from 'react-native';

function App() {
  const colorScheme = useColorScheme();
  // Returns: 'light' | 'dark' | null
  
  const isDarkMode = colorScheme === 'dark';
  
  return (
    <View style={[
      styles.container,
      { backgroundColor: isDarkMode ? '#121212' : '#ffffff' }
    ]}>
      <Text style={{ color: isDarkMode ? '#ffffff' : '#000000' }}>
        Current theme: {colorScheme}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
});

Understanding useColorScheme

Return Value Meaning
'light' System is in light mode
'dark' System is in dark mode
null Preference not available (rare, default to light)

✅ Automatic Updates

The useColorScheme hook automatically triggers a re-render when the user changes their system theme. No event listeners needed!

Simple Theme Selection

import { useColorScheme, View, Text } from 'react-native';

// Define color constants
const colors = {
  light: {
    background: '#ffffff',
    text: '#1a1a1a',
    primary: '#2196F3',
  },
  dark: {
    background: '#121212',
    text: '#e0e0e0',
    primary: '#bb86fc',
  },
};

function ThemedScreen() {
  const colorScheme = useColorScheme() ?? 'light';
  const theme = colors[colorScheme];
  
  return (
    <View style={{ flex: 1, backgroundColor: theme.background }}>
      <Text style={{ color: theme.text }}>
        Hello, themed world!
      </Text>
    </View>
  );
}

Building a Theme Structure

A well-organized theme structure makes your styles consistent and maintainable. Let's build a comprehensive theme object.

Theme Type Definitions

// theme/types.ts
export interface ThemeColors {
  // Backgrounds
  background: string;
  backgroundSecondary: string;
  backgroundTertiary: string;
  
  // Surfaces (cards, modals)
  surface: string;
  surfaceElevated: string;
  
  // Text
  text: string;
  textSecondary: string;
  textTertiary: string;
  textInverse: string;
  
  // Primary brand color
  primary: string;
  primaryLight: string;
  primaryDark: string;
  onPrimary: string;
  
  // Secondary brand color
  secondary: string;
  onSecondary: string;
  
  // Semantic colors
  success: string;
  warning: string;
  error: string;
  info: string;
  
  // Borders and dividers
  border: string;
  divider: string;
  
  // Interactive states
  disabled: string;
  placeholder: string;
}

export interface ThemeSpacing {
  xs: number;
  sm: number;
  md: number;
  lg: number;
  xl: number;
  xxl: number;
}

export interface ThemeTypography {
  h1: TextStyle;
  h2: TextStyle;
  h3: TextStyle;
  body: TextStyle;
  bodySmall: TextStyle;
  caption: TextStyle;
  button: TextStyle;
}

export interface ThemeShadows {
  sm: ViewStyle;
  md: ViewStyle;
  lg: ViewStyle;
}

export interface Theme {
  dark: boolean;
  colors: ThemeColors;
  spacing: ThemeSpacing;
  typography: ThemeTypography;
  shadows: ThemeShadows;
  borderRadius: {
    sm: number;
    md: number;
    lg: number;
    full: number;
  };
}

Light Theme Definition

// theme/lightTheme.ts
import { Theme } from './types';
import { Platform } from 'react-native';

export const lightTheme: Theme = {
  dark: false,
  
  colors: {
    // Backgrounds
    background: '#ffffff',
    backgroundSecondary: '#f5f5f5',
    backgroundTertiary: '#eeeeee',
    
    // Surfaces
    surface: '#ffffff',
    surfaceElevated: '#ffffff',
    
    // Text
    text: '#1a1a1a',
    textSecondary: '#666666',
    textTertiary: '#999999',
    textInverse: '#ffffff',
    
    // Primary (Blue)
    primary: '#2196F3',
    primaryLight: '#64b5f6',
    primaryDark: '#1976d2',
    onPrimary: '#ffffff',
    
    // Secondary (Orange)
    secondary: '#FF9800',
    onSecondary: '#000000',
    
    // Semantic
    success: '#4CAF50',
    warning: '#FFC107',
    error: '#F44336',
    info: '#2196F3',
    
    // Borders
    border: '#e0e0e0',
    divider: '#eeeeee',
    
    // States
    disabled: '#bdbdbd',
    placeholder: '#9e9e9e',
  },
  
  spacing: {
    xs: 4,
    sm: 8,
    md: 16,
    lg: 24,
    xl: 32,
    xxl: 48,
  },
  
  typography: {
    h1: {
      fontSize: 32,
      fontWeight: '700',
      lineHeight: 40,
    },
    h2: {
      fontSize: 24,
      fontWeight: '600',
      lineHeight: 32,
    },
    h3: {
      fontSize: 20,
      fontWeight: '600',
      lineHeight: 28,
    },
    body: {
      fontSize: 16,
      fontWeight: '400',
      lineHeight: 24,
    },
    bodySmall: {
      fontSize: 14,
      fontWeight: '400',
      lineHeight: 20,
    },
    caption: {
      fontSize: 12,
      fontWeight: '400',
      lineHeight: 16,
    },
    button: {
      fontSize: 16,
      fontWeight: '600',
      lineHeight: 24,
    },
  },
  
  shadows: {
    sm: Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 1 },
        shadowOpacity: 0.1,
        shadowRadius: 2,
      },
      android: {
        elevation: 2,
      },
    }) as any,
    md: Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 2 },
        shadowOpacity: 0.15,
        shadowRadius: 4,
      },
      android: {
        elevation: 4,
      },
    }) as any,
    lg: Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 4 },
        shadowOpacity: 0.2,
        shadowRadius: 8,
      },
      android: {
        elevation: 8,
      },
    }) as any,
  },
  
  borderRadius: {
    sm: 4,
    md: 8,
    lg: 16,
    full: 9999,
  },
};

Dark Theme Definition

// theme/darkTheme.ts
import { Theme } from './types';
import { lightTheme } from './lightTheme';

export const darkTheme: Theme = {
  ...lightTheme, // Inherit spacing, typography, shadows, borderRadius
  dark: true,
  
  colors: {
    // Backgrounds (Material Design dark theme guidelines)
    background: '#121212',
    backgroundSecondary: '#1e1e1e',
    backgroundTertiary: '#2c2c2c',
    
    // Surfaces (slightly elevated from background)
    surface: '#1e1e1e',
    surfaceElevated: '#2c2c2c',
    
    // Text (reduced contrast for dark mode comfort)
    text: '#e0e0e0',
    textSecondary: '#a0a0a0',
    textTertiary: '#6c6c6c',
    textInverse: '#1a1a1a',
    
    // Primary (lighter for dark backgrounds)
    primary: '#bb86fc',
    primaryLight: '#e1bee7',
    primaryDark: '#9c27b0',
    onPrimary: '#000000',
    
    // Secondary
    secondary: '#03DAC6',
    onSecondary: '#000000',
    
    // Semantic (slightly desaturated)
    success: '#81c784',
    warning: '#ffb74d',
    error: '#ef5350',
    info: '#64b5f6',
    
    // Borders
    border: '#3c3c3c',
    divider: '#2c2c2c',
    
    // States
    disabled: '#4a4a4a',
    placeholder: '#6c6c6c',
  },
};

⚠️ Dark Mode Design Guidelines

  • Don't use pure black (#000000): Use dark gray (#121212) for better readability
  • Reduce contrast: Use #e0e0e0 instead of #ffffff for text
  • Desaturate colors: Bright colors feel harsh on dark backgrounds
  • Maintain hierarchy: Use slightly lighter surfaces for elevation

Creating Theme Context

Context provides theme access throughout your component tree without prop drilling.

Theme Context Implementation

// context/ThemeContext.tsx
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { Theme } from '../theme/types';
import { lightTheme } from '../theme/lightTheme';
import { darkTheme } from '../theme/darkTheme';

type ThemeMode = 'light' | 'dark' | 'system';

interface ThemeContextType {
  theme: Theme;
  themeMode: ThemeMode;
  setThemeMode: (mode: ThemeMode) => void;
  isDark: boolean;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

interface ThemeProviderProps {
  children: ReactNode;
}

export function ThemeProvider({ children }: ThemeProviderProps) {
  const systemColorScheme = useColorScheme();
  const [themeMode, setThemeMode] = useState<ThemeMode>('system');
  
  // Determine actual theme based on mode
  const isDark = 
    themeMode === 'dark' || 
    (themeMode === 'system' && systemColorScheme === 'dark');
  
  const theme = isDark ? darkTheme : lightTheme;
  
  const value: ThemeContextType = {
    theme,
    themeMode,
    setThemeMode,
    isDark,
  };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// Custom hook for accessing theme
export function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  
  return context;
}

// Convenience hook for just the theme object
export function useThemeColors() {
  const { theme } = useTheme();
  return theme.colors;
}

Setting Up the Provider

// App.tsx
import { ThemeProvider } from './context/ThemeContext';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';

export default function App() {
  return (
    <SafeAreaProvider>
      <ThemeProvider>
        <ThemedApp />
      </ThemeProvider>
    </SafeAreaProvider>
  );
}

function ThemedApp() {
  const { isDark } = useTheme();
  
  return (
    <>
      <StatusBar style={isDark ? 'light' : 'dark'} />
      <Navigation />
    </>
  );
}
flowchart TB
    subgraph App["App Component"]
        TP["ThemeProvider"]
    end
    
    subgraph Context["Theme Context"]
        TM["themeMode: 'system'"]
        T["theme: lightTheme | darkTheme"]
        ST["setThemeMode()"]
    end
    
    subgraph Usage["Component Usage"]
        UT["useTheme()"]
        UTC["useThemeColors()"]
    end
    
    TP --> Context
    Context --> Usage
    
    style App fill:#e3f2fd
    style Context fill:#fff3e0
    style Usage fill:#e8f5e9
                

Building Themed Components

Create a library of themed components that automatically adapt to the current theme.

Themed Text Component

// components/themed/ThemedText.tsx
import { Text, TextProps, StyleSheet } from 'react-native';
import { useTheme } from '../../context/ThemeContext';

type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'bodySmall' | 'caption';
type TextColor = 'primary' | 'secondary' | 'tertiary' | 'error' | 'success' | 'brand';

interface ThemedTextProps extends TextProps {
  variant?: TextVariant;
  color?: TextColor;
}

export function ThemedText({ 
  variant = 'body', 
  color = 'primary',
  style, 
  ...props 
}: ThemedTextProps) {
  const { theme } = useTheme();
  
  const colorMap = {
    primary: theme.colors.text,
    secondary: theme.colors.textSecondary,
    tertiary: theme.colors.textTertiary,
    error: theme.colors.error,
    success: theme.colors.success,
    brand: theme.colors.primary,
  };
  
  return (
    <Text
      style={[
        theme.typography[variant],
        { color: colorMap[color] },
        style,
      ]}
      {...props}
    />
  );
}

// Usage
<ThemedText variant="h1">Welcome</ThemedText>
<ThemedText variant="body" color="secondary">Description</ThemedText>
<ThemedText variant="caption" color="error">Error message</ThemedText>

Themed View Component

// components/themed/ThemedView.tsx
import { View, ViewProps } from 'react-native';
import { useTheme } from '../../context/ThemeContext';

type BackgroundVariant = 'primary' | 'secondary' | 'tertiary' | 'surface' | 'elevated';

interface ThemedViewProps extends ViewProps {
  background?: BackgroundVariant;
}

export function ThemedView({ 
  background = 'primary', 
  style, 
  ...props 
}: ThemedViewProps) {
  const { theme } = useTheme();
  
  const backgroundMap = {
    primary: theme.colors.background,
    secondary: theme.colors.backgroundSecondary,
    tertiary: theme.colors.backgroundTertiary,
    surface: theme.colors.surface,
    elevated: theme.colors.surfaceElevated,
  };
  
  return (
    <View
      style={[{ backgroundColor: backgroundMap[background] }, style]}
      {...props}
    />
  );
}

Themed Card Component

// components/themed/Card.tsx
import { View, ViewProps, StyleSheet } from 'react-native';
import { useTheme } from '../../context/ThemeContext';

interface CardProps extends ViewProps {
  elevated?: boolean;
}

export function Card({ elevated = false, style, children, ...props }: CardProps) {
  const { theme } = useTheme();
  
  return (
    <View
      style={[
        styles.card,
        {
          backgroundColor: elevated 
            ? theme.colors.surfaceElevated 
            : theme.colors.surface,
          borderRadius: theme.borderRadius.lg,
        },
        elevated && theme.shadows.md,
        style,
      ]}
      {...props}
    >
      {children}
    </View>
  );
}

const styles = StyleSheet.create({
  card: {
    padding: 16,
  },
});

Themed Button Component

// components/themed/Button.tsx
import { Pressable, Text, StyleSheet, ViewStyle, Platform } from 'react-native';
import { useTheme } from '../../context/ThemeContext';

type ButtonVariant = 'filled' | 'outlined' | 'text';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
}

export function Button({ 
  title, 
  onPress, 
  variant = 'filled',
  size = 'md',
  disabled = false,
}: ButtonProps) {
  const { theme } = useTheme();
  
  const sizeStyles = {
    sm: { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 },
    md: { paddingVertical: 12, paddingHorizontal: 20, fontSize: 16 },
    lg: { paddingVertical: 16, paddingHorizontal: 24, fontSize: 18 },
  };
  
  const getButtonStyle = (): ViewStyle => {
    const base: ViewStyle = {
      borderRadius: theme.borderRadius.md,
      alignItems: 'center',
      justifyContent: 'center',
      paddingVertical: sizeStyles[size].paddingVertical,
      paddingHorizontal: sizeStyles[size].paddingHorizontal,
    };
    
    if (disabled) {
      return {
        ...base,
        backgroundColor: theme.colors.disabled,
      };
    }
    
    switch (variant) {
      case 'filled':
        return {
          ...base,
          backgroundColor: theme.colors.primary,
          ...theme.shadows.sm,
        };
      case 'outlined':
        return {
          ...base,
          backgroundColor: 'transparent',
          borderWidth: 1,
          borderColor: theme.colors.primary,
        };
      case 'text':
        return {
          ...base,
          backgroundColor: 'transparent',
        };
    }
  };
  
  const getTextColor = () => {
    if (disabled) return theme.colors.textTertiary;
    if (variant === 'filled') return theme.colors.onPrimary;
    return theme.colors.primary;
  };
  
  return (
    <Pressable
      onPress={onPress}
      disabled={disabled}
      style={({ pressed }) => [
        getButtonStyle(),
        Platform.OS === 'ios' && pressed && styles.pressed,
      ]}
      android_ripple={{
        color: variant === 'filled' 
          ? 'rgba(255,255,255,0.2)' 
          : `${theme.colors.primary}20`,
      }}
    >
      <Text style={[
        theme.typography.button,
        { color: getTextColor(), fontSize: sizeStyles[size].fontSize },
      ]}>
        {title}
      </Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  pressed: {
    opacity: 0.7,
  },
});

Themed Input Component

// components/themed/Input.tsx
import { View, TextInput, Text, StyleSheet, TextInputProps } from 'react-native';
import { useTheme } from '../../context/ThemeContext';

interface InputProps extends TextInputProps {
  label?: string;
  error?: string;
}

export function Input({ label, error, style, ...props }: InputProps) {
  const { theme } = useTheme();
  
  return (
    <View style={styles.container}>
      {label && (
        <Text style={[styles.label, { color: theme.colors.textSecondary }]}>
          {label}
        </Text>
      )}
      
      <TextInput
        style={[
          styles.input,
          {
            backgroundColor: theme.colors.backgroundSecondary,
            color: theme.colors.text,
            borderColor: error ? theme.colors.error : theme.colors.border,
            borderRadius: theme.borderRadius.md,
          },
          style,
        ]}
        placeholderTextColor={theme.colors.placeholder}
        {...props}
      />
      
      {error && (
        <Text style={[styles.error, { color: theme.colors.error }]}>
          {error}
        </Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    marginBottom: 16,
  },
  label: {
    fontSize: 14,
    fontWeight: '500',
    marginBottom: 6,
  },
  input: {
    height: 48,
    paddingHorizontal: 16,
    fontSize: 16,
    borderWidth: 1,
  },
  error: {
    fontSize: 12,
    marginTop: 4,
  },
});

Export All Themed Components

// components/themed/index.ts
export { ThemedText } from './ThemedText';
export { ThemedView } from './ThemedView';
export { Card } from './Card';
export { Button } from './Button';
export { Input } from './Input';

// Usage in screens
import { ThemedText, ThemedView, Card, Button, Input } from '../components/themed';

Dynamic Styles with useTheme

For more complex components, create styles dynamically based on the theme.

useThemedStyles Hook

// hooks/useThemedStyles.ts
import { useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';
import { Theme } from '../theme/types';

type StyleFactory<T> = (theme: Theme) => T;

export function useThemedStyles<T extends StyleSheet.NamedStyles<T>>(
  styleFactory: StyleFactory<T>
): T {
  const { theme } = useTheme();
  
  return useMemo(() => styleFactory(theme), [theme, styleFactory]);
}

// Usage
function ProfileScreen() {
  const styles = useThemedStyles((theme) => StyleSheet.create({
    container: {
      flex: 1,
      backgroundColor: theme.colors.background,
      padding: theme.spacing.md,
    },
    header: {
      ...theme.typography.h1,
      color: theme.colors.text,
      marginBottom: theme.spacing.lg,
    },
    card: {
      backgroundColor: theme.colors.surface,
      borderRadius: theme.borderRadius.lg,
      padding: theme.spacing.md,
      ...theme.shadows.md,
    },
    label: {
      ...theme.typography.caption,
      color: theme.colors.textSecondary,
      marginBottom: theme.spacing.xs,
    },
    value: {
      ...theme.typography.body,
      color: theme.colors.text,
    },
  }));
  
  return (
    <View style={styles.container}>
      <Text style={styles.header}>Profile</Text>
      <View style={styles.card}>
        <Text style={styles.label}>Name</Text>
        <Text style={styles.value}>John Doe</Text>
      </View>
    </View>
  );
}

Inline Theme Usage

// For simpler cases, use theme directly
function SimpleComponent() {
  const { theme } = useTheme();
  
  return (
    <View style={{ 
      backgroundColor: theme.colors.background,
      padding: theme.spacing.md,
    }}>
      <Text style={{ 
        ...theme.typography.h2,
        color: theme.colors.text,
      }}>
        Title
      </Text>
    </View>
  );
}

Style Prop Pattern

// Allow style overrides in themed components
interface ThemedCardProps extends ViewProps {
  variant?: 'default' | 'outlined';
}

function ThemedCard({ variant = 'default', style, children, ...props }: ThemedCardProps) {
  const { theme } = useTheme();
  
  const cardStyle: ViewStyle = {
    backgroundColor: theme.colors.surface,
    borderRadius: theme.borderRadius.lg,
    padding: theme.spacing.md,
    ...(variant === 'outlined' 
      ? { borderWidth: 1, borderColor: theme.colors.border }
      : theme.shadows.md
    ),
  };
  
  return (
    <View style={[cardStyle, style]} {...props}>
      {children}
    </View>
  );
}

// Usage with overrides
<ThemedCard style={{ marginBottom: 16 }}>
  <Content />
</ThemedCard>

Manual Theme Switching

Allow users to choose between light, dark, or system theme preference.

Theme Selector Component

// components/ThemeSelector.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';

type ThemeOption = 'light' | 'dark' | 'system';

const options: { value: ThemeOption; label: string; icon: string }[] = [
  { value: 'light', label: 'Light', icon: '☀️' },
  { value: 'dark', label: 'Dark', icon: '🌙' },
  { value: 'system', label: 'System', icon: '⚙️' },
];

export function ThemeSelector() {
  const { theme, themeMode, setThemeMode } = useTheme();
  
  return (
    <View style={styles.container}>
      <Text style={[styles.title, { color: theme.colors.text }]}>
        Appearance
      </Text>
      
      <View style={[styles.options, { backgroundColor: theme.colors.backgroundSecondary }]}>
        {options.map((option) => {
          const isSelected = themeMode === option.value;
          
          return (
            <Pressable
              key={option.value}
              style={[
                styles.option,
                isSelected && { backgroundColor: theme.colors.primary },
              ]}
              onPress={() => setThemeMode(option.value)}
            >
              <Text style={styles.icon}>{option.icon}</Text>
              <Text style={[
                styles.optionText,
                { color: isSelected ? theme.colors.onPrimary : theme.colors.text },
              ]}>
                {option.label}
              </Text>
            </Pressable>
          );
        })}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
  },
  title: {
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 12,
  },
  options: {
    flexDirection: 'row',
    borderRadius: 12,
    padding: 4,
  },
  option: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    paddingVertical: 10,
    paddingHorizontal: 12,
    borderRadius: 8,
    gap: 6,
  },
  icon: {
    fontSize: 16,
  },
  optionText: {
    fontSize: 14,
    fontWeight: '500',
  },
});
☀️ Light 🌙 Dark ⚙️ System

Persisting Theme Preference

Save the user's theme choice so it persists between app launches.

Updated ThemeProvider with Persistence

// context/ThemeContext.tsx (updated)
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Theme } from '../theme/types';
import { lightTheme } from '../theme/lightTheme';
import { darkTheme } from '../theme/darkTheme';

type ThemeMode = 'light' | 'dark' | 'system';

const THEME_STORAGE_KEY = '@app_theme_mode';

interface ThemeContextType {
  theme: Theme;
  themeMode: ThemeMode;
  setThemeMode: (mode: ThemeMode) => void;
  isDark: boolean;
  isLoading: boolean;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const systemColorScheme = useColorScheme();
  const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
  const [isLoading, setIsLoading] = useState(true);
  
  // Load saved preference on mount
  useEffect(() => {
    async function loadThemePreference() {
      try {
        const savedMode = await AsyncStorage.getItem(THEME_STORAGE_KEY);
        if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
          setThemeModeState(savedMode as ThemeMode);
        }
      } catch (error) {
        console.error('Failed to load theme preference:', error);
      } finally {
        setIsLoading(false);
      }
    }
    
    loadThemePreference();
  }, []);
  
  // Save preference when it changes
  const setThemeMode = async (mode: ThemeMode) => {
    setThemeModeState(mode);
    try {
      await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
    } catch (error) {
      console.error('Failed to save theme preference:', error);
    }
  };
  
  const isDark = 
    themeMode === 'dark' || 
    (themeMode === 'system' && systemColorScheme === 'dark');
  
  const theme = isDark ? darkTheme : lightTheme;
  
  const value: ThemeContextType = {
    theme,
    themeMode,
    setThemeMode,
    isDark,
    isLoading,
  };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  
  return context;
}

Handling Loading State

// App.tsx
import { ActivityIndicator, View } from 'react-native';
import { ThemeProvider, useTheme } from './context/ThemeContext';

function App() {
  return (
    <ThemeProvider>
      <AppContent />
    </ThemeProvider>
  );
}

function AppContent() {
  const { isLoading, theme } = useTheme();
  
  // Show loading while theme preference is being loaded
  if (isLoading) {
    return (
      <View style={{ 
        flex: 1, 
        justifyContent: 'center', 
        alignItems: 'center',
        backgroundColor: '#121212', // Use dark to avoid flash
      }}>
        <ActivityIndicator size="large" color="#bb86fc" />
      </View>
    );
  }
  
  return <Navigation />;
}

✅ Pro Tip: Avoid White Flash

Use a dark splash screen and dark loading state to avoid a jarring white flash when dark mode users open the app. The splash screen should match the most common expected theme.

Status Bar and Navigation Theming

Complete theme integration requires styling the status bar and navigation to match your theme.

Status Bar Theming

// Using Expo StatusBar
import { StatusBar } from 'expo-status-bar';
import { useTheme } from '../context/ThemeContext';

function ThemedStatusBar() {
  const { isDark } = useTheme();
  
  return (
    <StatusBar 
      style={isDark ? 'light' : 'dark'} 
      backgroundColor={isDark ? '#121212' : '#ffffff'}
    />
  );
}

// Include in your app root
function App() {
  return (
    <ThemeProvider>
      <ThemedStatusBar />
      <Navigation />
    </ThemeProvider>
  );
}

React Navigation Theming

React Navigation has built-in theme support that integrates with your custom theme.

// navigation/theme.ts
import { Theme as NavigationTheme } from '@react-navigation/native';
import { Theme } from '../theme/types';

export function createNavigationTheme(theme: Theme): NavigationTheme {
  return {
    dark: theme.dark,
    colors: {
      primary: theme.colors.primary,
      background: theme.colors.background,
      card: theme.colors.surface,
      text: theme.colors.text,
      border: theme.colors.border,
      notification: theme.colors.error,
    },
  };
}
// navigation/Navigation.tsx
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useTheme } from '../context/ThemeContext';
import { createNavigationTheme } from './theme';

const Stack = createNativeStackNavigator();

export function Navigation() {
  const { theme, isDark } = useTheme();
  const navigationTheme = createNavigationTheme(theme);
  
  return (
    <NavigationContainer theme={navigationTheme}>
      <Stack.Navigator
        screenOptions={{
          headerStyle: {
            backgroundColor: theme.colors.surface,
          },
          headerTintColor: theme.colors.text,
          headerTitleStyle: {
            ...theme.typography.h3,
            color: theme.colors.text,
          },
          headerShadowVisible: false,
          contentStyle: {
            backgroundColor: theme.colors.background,
          },
        }}
      >
        <Stack.Screen name="Home" component={HomeScreen} />
        <Stack.Screen name="Settings" component={SettingsScreen} />
        <Stack.Screen name="Profile" component={ProfileScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

Bottom Tab Navigator Theming

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useTheme } from '../context/ThemeContext';

const Tab = createBottomTabNavigator();

function TabNavigator() {
  const { theme } = useTheme();
  
  return (
    <Tab.Navigator
      screenOptions={{
        tabBarStyle: {
          backgroundColor: theme.colors.surface,
          borderTopColor: theme.colors.border,
        },
        tabBarActiveTintColor: theme.colors.primary,
        tabBarInactiveTintColor: theme.colors.textSecondary,
        headerStyle: {
          backgroundColor: theme.colors.surface,
        },
        headerTintColor: theme.colors.text,
      }}
    >
      <Tab.Screen 
        name="Home" 
        component={HomeScreen}
        options={{
          tabBarIcon: ({ color }) => <HomeIcon color={color} />,
        }}
      />
      <Tab.Screen 
        name="Settings" 
        component={SettingsScreen}
        options={{
          tabBarIcon: ({ color }) => <SettingsIcon color={color} />,
        }}
      />
    </Tab.Navigator>
  );
}

Modal Theming

function ThemedModal({ visible, onClose, children }) {
  const { theme } = useTheme();
  
  return (
    <Modal
      visible={visible}
      transparent
      animationType="fade"
      onRequestClose={onClose}
    >
      <View style={styles.overlay}>
        <View style={[
          styles.modal,
          {
            backgroundColor: theme.colors.surface,
            borderRadius: theme.borderRadius.lg,
            ...theme.shadows.lg,
          }
        ]}>
          {children}
        </View>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  overlay: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
    padding: 24,
  },
  modal: {
    width: '100%',
    maxWidth: 400,
    padding: 24,
  },
});

Best Practices

1. Define Semantic Colors

// ✅ Good - semantic naming
colors: {
  background: '#ffffff',
  text: '#1a1a1a',
  textSecondary: '#666666',
  error: '#f44336',
}

// ❌ Bad - literal color names
colors: {
  white: '#ffffff',
  black: '#1a1a1a',
  gray: '#666666',
  red: '#f44336',
}

2. Use Theme Tokens Consistently

// ✅ Good - use theme tokens
<View style={{ padding: theme.spacing.md }}>
  <Text style={{ color: theme.colors.text }}>Hello</Text>
</View>

// ❌ Bad - hardcoded values
<View style={{ padding: 16 }}>
  <Text style={{ color: '#1a1a1a' }}>Hello</Text>
</View>

3. Create Themed Component Library

// Build a complete set of themed primitives
export { ThemedText } from './ThemedText';
export { ThemedView } from './ThemedView';
export { Card } from './Card';
export { Button } from './Button';
export { Input } from './Input';
export { Divider } from './Divider';
export { Avatar } from './Avatar';
export { Badge } from './Badge';

// Then use them exclusively
import { ThemedText, Card, Button } from '../components/themed';

4. Test Both Themes

💡 Testing Checklist

  • ☐ All text is readable in both themes
  • ☐ Interactive elements are clearly visible
  • ☐ Images/icons work on both backgrounds
  • ☐ Status bar contrasts with header
  • ☐ Disabled states are distinguishable
  • ☐ Error/success states are visible
  • ☐ No hardcoded colors bypass the theme

5. Handle Images and Icons

// For icons that need to adapt
function ThemedIcon({ name, size = 24 }) {
  const { theme } = useTheme();
  return <Icon name={name} size={size} color={theme.colors.text} />;
}

// For images that need dark variants
function ThemedLogo() {
  const { isDark } = useTheme();
  
  return (
    <Image
      source={isDark 
        ? require('../assets/logo-dark.png')
        : require('../assets/logo-light.png')
      }
      style={styles.logo}
    />
  );
}

6. Smooth Theme Transitions

// Consider animating theme changes for smoother UX
import { LayoutAnimation, Platform, UIManager } from 'react-native';

// Enable LayoutAnimation on Android
if (Platform.OS === 'android') {
  UIManager.setLayoutAnimationEnabledExperimental?.(true);
}

// In setThemeMode
const setThemeMode = async (mode: ThemeMode) => {
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
  setThemeModeState(mode);
  // ... persist
};

Complete File Structure

src/
├── theme/
│   ├── index.ts          # Export all theme items
│   ├── types.ts          # TypeScript interfaces
│   ├── lightTheme.ts     # Light theme definition
│   └── darkTheme.ts      # Dark theme definition
├── context/
│   └── ThemeContext.tsx  # Theme provider and hooks
├── components/
│   └── themed/
│       ├── index.ts      # Export all components
│       ├── ThemedText.tsx
│       ├── ThemedView.tsx
│       ├── Card.tsx
│       ├── Button.tsx
│       └── Input.tsx
├── hooks/
│   └── useThemedStyles.ts
└── navigation/
    ├── theme.ts          # Navigation theme adapter
    └── Navigation.tsx    # Themed navigator

Hands-On Exercises

Exercise 1: Create a Theme Toggle Switch

Build a toggle switch component that switches between light and dark mode, displaying the current mode.

Show Solution
import { View, Text, Switch, StyleSheet } from 'react-native';
import { useTheme } from '../context/ThemeContext';

function ThemeToggle() {
  const { theme, isDark, setThemeMode } = useTheme();
  
  const handleToggle = (value: boolean) => {
    setThemeMode(value ? 'dark' : 'light');
  };
  
  return (
    <View style={[styles.container, { backgroundColor: theme.colors.surface }]}>
      <View style={styles.row}>
        <Text style={styles.icon}>☀️</Text>
        <Switch
          value={isDark}
          onValueChange={handleToggle}
          trackColor={{ 
            false: theme.colors.border, 
            true: theme.colors.primary 
          }}
          thumbColor={theme.colors.surface}
        />
        <Text style={styles.icon}>🌙</Text>
      </View>
      <Text style={[styles.label, { color: theme.colors.textSecondary }]}>
        {isDark ? 'Dark Mode' : 'Light Mode'}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
  },
  icon: {
    fontSize: 20,
  },
  label: {
    marginTop: 8,
    fontSize: 14,
  },
});

Exercise 2: Build a Themed Settings Screen

Create a complete settings screen with themed list items, a theme selector, and proper dark mode support.

Show Solution
import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../context/ThemeContext';
import { ThemeSelector } from '../components/ThemeSelector';

function SettingsScreen() {
  const { theme } = useTheme();
  
  const settingsGroups = [
    {
      title: 'Appearance',
      items: [
        { icon: '🎨', label: 'Theme', component: <ThemeSelector /> },
      ],
    },
    {
      title: 'Account',
      items: [
        { icon: '👤', label: 'Profile', onPress: () => {} },
        { icon: '🔔', label: 'Notifications', onPress: () => {} },
        { icon: '🔒', label: 'Privacy', onPress: () => {} },
      ],
    },
    {
      title: 'Support',
      items: [
        { icon: '❓', label: 'Help Center', onPress: () => {} },
        { icon: '📧', label: 'Contact Us', onPress: () => {} },
      ],
    },
  ];
  
  return (
    <SafeAreaView style={[styles.container, { backgroundColor: theme.colors.background }]}>
      <ScrollView>
        <Text style={[styles.screenTitle, { color: theme.colors.text }]}>
          Settings
        </Text>
        
        {settingsGroups.map((group) => (
          <View key={group.title} style={styles.group}>
            <Text style={[styles.groupTitle, { color: theme.colors.textSecondary }]}>
              {group.title}
            </Text>
            
            <View style={[styles.groupContent, { backgroundColor: theme.colors.surface }]}>
              {group.items.map((item, index) => (
                <View key={item.label}>
                  {item.component ? (
                    item.component
                  ) : (
                    <Pressable
                      style={styles.settingsItem}
                      onPress={item.onPress}
                    >
                      <Text style={styles.itemIcon}>{item.icon}</Text>
                      <Text style={[styles.itemLabel, { color: theme.colors.text }]}>
                        {item.label}
                      </Text>
                      <Text style={{ color: theme.colors.textTertiary }}>›</Text>
                    </Pressable>
                  )}
                  
                  {index < group.items.length - 1 && (
                    <View style={[styles.divider, { backgroundColor: theme.colors.divider }]} />
                  )}
                </View>
              ))}
            </View>
          </View>
        ))}
      </ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  screenTitle: {
    fontSize: 34,
    fontWeight: 'bold',
    padding: 16,
  },
  group: {
    marginBottom: 24,
  },
  groupTitle: {
    fontSize: 13,
    fontWeight: '600',
    textTransform: 'uppercase',
    marginLeft: 16,
    marginBottom: 8,
  },
  groupContent: {
    marginHorizontal: 16,
    borderRadius: 12,
    overflow: 'hidden',
  },
  settingsItem: {
    flexDirection: 'row',
    alignItems: 'center',
    padding: 16,
  },
  itemIcon: {
    fontSize: 20,
    marginRight: 12,
  },
  itemLabel: {
    flex: 1,
    fontSize: 16,
  },
  divider: {
    height: 1,
    marginLeft: 52,
  },
});

Exercise 3: Create a useThemedStyles Hook

Build a custom hook that creates memoized styles based on the current theme.

Show Solution
import { useMemo, useCallback } from 'react';
import { StyleSheet, ViewStyle, TextStyle, ImageStyle } from 'react-native';
import { useTheme } from '../context/ThemeContext';
import { Theme } from '../theme/types';

type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
type StyleFactory<T extends NamedStyles<T>> = (theme: Theme) => T;

export function useThemedStyles<T extends NamedStyles<T>>(
  factory: StyleFactory<T>
): T {
  const { theme } = useTheme();
  
  // Memoize the factory to prevent unnecessary recreations
  const stableFactory = useCallback(factory, []);
  
  // Memoize styles based on theme
  const styles = useMemo(
    () => StyleSheet.create(stableFactory(theme)),
    [theme, stableFactory]
  );
  
  return styles;
}

// Usage example
function ProfileCard() {
  const styles = useThemedStyles((theme) => ({
    container: {
      backgroundColor: theme.colors.surface,
      borderRadius: theme.borderRadius.lg,
      padding: theme.spacing.md,
      ...theme.shadows.md,
    },
    avatar: {
      width: 64,
      height: 64,
      borderRadius: 32,
      backgroundColor: theme.colors.primary,
      marginBottom: theme.spacing.sm,
    },
    name: {
      ...theme.typography.h3,
      color: theme.colors.text,
    },
    bio: {
      ...theme.typography.body,
      color: theme.colors.textSecondary,
      marginTop: theme.spacing.xs,
    },
  }));
  
  return (
    <View style={styles.container}>
      <View style={styles.avatar} />
      <Text style={styles.name}>John Doe</Text>
      <Text style={styles.bio}>Software Developer</Text>
    </View>
  );
}

Summary

Congratulations! You've completed Module 4 and built a complete theming system with dark mode support.

🎯 Key Takeaways

  • useColorScheme detects system theme preference automatically
  • Theme structure should include colors, spacing, typography, and shadows
  • ThemeContext provides app-wide theme access
  • Themed components adapt automatically to theme changes
  • Theme selector allows users to choose light, dark, or system
  • AsyncStorage persists theme preference between sessions
  • Navigation theming requires passing theme to NavigationContainer

Module 4 Complete! 🎉

You've now mastered React Native styling:

Lesson Topic
4.1 StyleSheet Fundamentals
4.2 Flexbox in React Native
4.3 Common Layout Patterns
4.4 Responsive Design
4.5 Platform-Specific Styles
4.6 Animation Basics
4.7 Theming and Dark Mode ✓

Quick Reference

// Detect system theme
const colorScheme = useColorScheme(); // 'light' | 'dark' | null

// Use theme context
const { theme, isDark, setThemeMode } = useTheme();

// Apply themed styles
<View style={{ backgroundColor: theme.colors.background }}>
  <Text style={{ color: theme.colors.text }}>Hello</Text>
</View>

// Themed components
import { ThemedText, Card, Button } from './components/themed';

// Persist preference
await AsyncStorage.setItem('@theme_mode', 'dark');
const saved = await AsyncStorage.getItem('@theme_mode');

Coming Up Next

In Module 5: Navigation Fundamentals, you'll learn how to build complete navigation systems with React Navigation, including stack navigation, tab navigation, drawer navigation, and deep linking.