useModeToggle

PreviousNext

A hook that provides complete control over theme mode switching with support for light, dark, and system modes.

Installation

pnpm dlx bna-ui add useModeToggle

Requirements

The mode lives in ModeProvider, not in this hook, so one has to be mounted above every component that calls useModeToggle. The ThemeProvider you already wrap your app in mounts it for you — if you use that, there is nothing to do:

import { ThemeProvider } from '@/providers/theme-provider';
 
export default function RootLayout() {
  return (
    <ThemeProvider>
      <Stack />
    </ThemeProvider>
  );
}

Calling the hook without a provider throws rather than silently doing nothing.

Sharing the state this way is what makes the toggle behave: every toggle in the app agrees on the current mode, remounting a screen no longer resets the cycle, and the theme changes on web as well as native.

Usage

import { useModeToggle } from '@/hooks/useModeToggle';
export function ThemeToggle() {
  const { isDark, mode, setMode, toggleMode } = useModeToggle();

  return (
    <View>
      <Text>Current mode: {mode}</Text>
      <Text>Is dark: {isDark ? 'Yes' : 'No'}</Text>
      <TouchableOpacity onPress={toggleMode}>
        <Text>Toggle Theme</Text>
      </TouchableOpacity>
    </View>
  );
}

API Reference

useModeToggle

A hook that provides complete control over theme mode switching with support for light, dark, and system modes.

Returns

PropertyTypeDescription
isDarkbooleanWhether the current effective theme is dark
mode'light' | 'dark' | 'system'The currently selected mode setting
setMode(mode: Mode) => voidFunction to set a specific mode
currentMode'light' | 'dark'The resolved color scheme, with 'system' already applied
toggleMode() => voidFunction to cycle through modes: light → dark → system → light

Type Definitions

type Mode = 'light' | 'dark' | 'system';
 
interface UseModeToggleReturn {
  isDark: boolean;
  mode: Mode;
  setMode: (mode: Mode) => void;
  currentMode: 'light' | 'dark';
  toggleMode: () => void;
}

Mode Behavior

Setting a mode writes it to ModeProvider, which every useColorScheme call in the app reads — that is what repaints your components. On native the choice is additionally mirrored into React Native's global Appearance, so the status bar, the Android navigation bar and native sheets follow along.

Light Mode

  • Forces the app to use light theme regardless of system preference
  • isDark returns false
  • currentMode returns 'light'

Dark Mode

  • Forces the app to use dark theme regardless of system preference
  • isDark returns true
  • currentMode returns 'dark'

System Mode

  • Follows the system's color scheme preference
  • isDark reflects the actual system preference
  • currentMode returns the system's current preference

Toggle Cycle

The toggleMode function cycles through modes in this order:

light → dark → system → light → ...

This provides an intuitive way for users to quickly switch between all available options.

Use Cases

This hook is perfect for:

  • Creating theme toggle buttons in settings screens
  • Building comprehensive theme selection interfaces
  • Implementing persistent theme preferences
  • Providing users with granular control over app appearance
  • Creating theme-aware components that need to know the current mode
  • Building accessibility-compliant theme switching

Best Practices

Settings Screen Implementation

Create a comprehensive settings screen with theme options:

export function ThemeSettings() {
  const { mode, setMode, isDark, currentMode } = useModeToggle();
 
  const options = [
    { key: 'light', label: 'Light', icon: '☀️' },
    { key: 'dark', label: 'Dark', icon: '🌙' },
    { key: 'system', label: 'System', icon: '⚙️' },
  ];
 
  return (
    <View style={{ padding: 20 }}>
      <Text style={{ fontSize: 18, marginBottom: 16 }}>Theme</Text>
      {options.map((option) => (
        <TouchableOpacity
          key={option.key}
          style={{
            flexDirection: 'row',
            alignItems: 'center',
            paddingVertical: 12,
            backgroundColor: mode === option.key ? '#007AFF20' : 'transparent',
            borderRadius: 8,
            paddingHorizontal: 12,
          }}
          onPress={() => setMode(option.key as Mode)}
        >
          <Text style={{ marginRight: 12 }}>{option.icon}</Text>
          <Text style={{ flex: 1 }}>{option.label}</Text>
          {mode === option.key && <Text>✓</Text>}
        </TouchableOpacity>
      ))}
      <Text style={{ marginTop: 16, opacity: 0.6 }}>
        Current: {currentMode} (Effective: {isDark ? 'dark' : 'light'})
      </Text>
    </View>
  );
}

Persistent Theme Storage

Persistence is a prop on the provider, not something to wrap this hook in. Pass any key/value store — expo-secure-store matches the shape as-is, and is what every npx bna-ui init scaffold uses:

import * as SecureStore from 'expo-secure-store';
import { ThemeProvider } from '@/providers/theme-provider';
 
export default function RootLayout() {
  return (
    <ThemeProvider storage={SecureStore}>
      <Stack />
    </ThemeProvider>
  );
}

The mode is restored on mount and written on every change. A missing or unreadable value falls back to defaultMode, so a failed read can never block startup — including on web, where SecureStore is unavailable and persistence simply no-ops. See ModeProvider for the full contract.

Animated Theme Toggle

Create smooth transitions between theme modes:

import { useEffect, useRef } from 'react';
import { Animated } from 'react-native';
 
export function AnimatedThemeToggle() {
  const { isDark, toggleMode } = useModeToggle();
  const animatedValue = useRef(new Animated.Value(isDark ? 1 : 0)).current;
 
  useEffect(() => {
    Animated.timing(animatedValue, {
      toValue: isDark ? 1 : 0,
      duration: 300,
      useNativeDriver: false,
    }).start();
  }, [isDark]);
 
  const backgroundColor = animatedValue.interpolate({
    inputRange: [0, 1],
    outputRange: ['#ffffff', '#000000'],
  });
 
  return (
    <Animated.View style={{ backgroundColor, flex: 1 }}>
      <TouchableOpacity onPress={toggleMode}>
        <Text>Toggle Theme</Text>
      </TouchableOpacity>
    </Animated.View>
  );
}

Header Integration

Integrate theme toggle into your app header:

export function AppHeader() {
  const { mode, toggleMode } = useModeToggle();
 
  const getToggleIcon = () => {
    switch (mode) {
      case 'light':
        return '☀️';
      case 'dark':
        return '🌙';
      case 'system':
        return '⚙️';
    }
  };
 
  return (
    <View
      style={{
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        padding: 16,
      }}
    >
      <Text style={{ fontSize: 18, fontWeight: 'bold' }}>My App</Text>
      <TouchableOpacity onPress={toggleMode}>
        <Text style={{ fontSize: 20 }}>{getToggleIcon()}</Text>
      </TouchableOpacity>
    </View>
  );
}

Performance Considerations

Memoization

The hook uses internal state management and is already optimized, but you can memoize dependent calculations:

const themeStyles = useMemo(
  () => ({
    container: {
      backgroundColor: isDark ? '#000' : '#fff',
      color: isDark ? '#fff' : '#000',
    },
  }),
  [isDark]
);

Avoiding Unnecessary Re-renders

Only destructure the values you actually need:

// Good: Only get what you need
const { isDark, toggleMode } = useModeToggle();
 
// Less optimal: Getting all values when you only need some
const modeToggle = useModeToggle();

Advanced Usage

Custom Mode Validation

Add validation for supported modes:

export function useValidatedModeToggle() {
  const modeToggle = useModeToggle();
 
  const setModeWithValidation = (mode: string) => {
    if (['light', 'dark', 'system'].includes(mode)) {
      modeToggle.setMode(mode as Mode);
    } else {
      console.warn(`Invalid mode: ${mode}`);
    }
  };
 
  return {
    ...modeToggle,
    setMode: setModeWithValidation,
  };
}

Reading the Mode Without the Toggle

You do not need a context of your own — the mode already lives in one. For components that only read the theme, use useColorScheme or useColor; both resolve through the same provider. To read or set the mode without the light → dark → system cycle, reach for useModeContext:

import { useModeContext } from '@/providers/mode-provider';
 
export function ThemeLabel() {
  const { mode, scheme } = useModeContext() ?? {};
 
  return (
    <Text>
      {mode} (rendering as {scheme})
    </Text>
  );
}

Dependencies

  • @/providers/mode-provider - Holds the mode and resolves it to a scheme
  • react - Required for context

Accessibility

The hook supports accessibility by:

  • Providing programmatic access to theme state for screen readers
  • Enabling proper contrast ratios through theme mode control
  • Supporting system-level accessibility preferences in system mode
  • Allowing users to override system preferences when needed

Platform Support

All three modes work on every platform, because the mode is held in React context rather than pushed through a platform API.

iOS

  • System mode respects iOS system preferences
  • The choice is mirrored into Appearance.setColorScheme, so the status bar and native sheets follow

Android

  • System mode respects Android system preferences
  • The choice is mirrored into Appearance.setColorScheme, so the status bar and the navigation bar follow

Web

  • System mode detection works through useColorScheme
  • react-native-web's Appearance is read-only — it has getColorScheme and addChangeListener but no setter — so the provider is the only thing driving the theme here. That is by design, and why the toggle works on web