- Accordion
- Action Sheet
- Alert Dialog
- Alert
- Audio Player
- Audio Recorder
- Audio Waveform
- Avatar
- AvoidKeyboard
- Badge
- BottomSheet
- Button
- Camera Preview
- Camera
- Card
- Carousel
- Checkbox
- Collapsible
- Color Picker
- Combobox
- Date Picker
- File Picker
- Gallery
- Hello Wave
- Icon
- Image
- Input OTP
- Input
- Link
- MediaPicker
- Mode Toggle
- Onboarding
- ParallaxScrollView
- Picker
- Popover
- Progress
- Radio
- ScrollView
- SearchBar
- Separator
- Share
- Sheet
- Skeleton
- Spinner
- Switch
- Table
- Tabs
- Text
- Toast
- Toggle
- Video
- View
Installation
pnpm dlx bna-ui add mode-provider
Usage
ThemeProvider mounts this for you, so most apps
never import it directly — and every scaffold from npx bna-ui init is already
set up. Mount it yourself only if you are not using ThemeProvider:
import { ModeProvider } from '@/providers/mode-provider';
export default function RootLayout() {
return (
<ModeProvider>
<Stack />
</ModeProvider>
);
}Everything below it — useColorScheme,
useColor, and therefore every component in the
library — resolves its colors through this provider.
Persisting the Choice
Pass any key/value store. expo-secure-store — what every scaffold from
npx bna-ui init uses — already matches the shape:
import * as SecureStore from 'expo-secure-store';
import { ThemeProvider } from '@/providers/theme-provider';
export default function RootLayout() {
return (
<ThemeProvider storage={SecureStore}>
<Stack />
</ThemeProvider>
);
}SecureStore has no web implementation, so on web this degrades to no persistence
rather than erroring — the toggle itself still works there. Reach for
AsyncStorage instead if persisting on web matters more to you than using the
store the scaffolds already depend on.
The mode is restored once on mount and written on every change. Nothing is
persisted unless you pass storage, which is why this file adds no
dependencies — you choose the storage engine, and apps that do not want one do
not pay for it. A missing, malformed or unreadable value falls back to
defaultMode, and a store that throws — including one that is simply
unavailable on the current platform — is swallowed, so persistence can never
block startup.
API Reference
ModeProvider
Holds the app-wide theme mode (light, dark or system) and resolves it to a concrete scheme. useColorScheme reads it when mounted, so an in-app toggle works on native *and* web — react-native-web has no Appearance.setColorScheme to write an override through. On native the choice is also mirrored into React Native Appearance, so the status bar and Android navigation bar follow. ThemeProvider mounts this for you.
| Prop | Type | Required | Description |
|---|---|---|---|
children | React.ReactNode | Yes | The wrapped app content. |
storage | ModeStorage | No | A key/value store — { getItem, setItem }, sync or async, which expo-secure-store and AsyncStorage both satisfy as-is — used to persist the choice across launches. Omit it and the mode resets to defaultMode on every launch. A missing, malformed or unreadable value falls back to defaultMode, so a store that is unavailable on the current platform degrades to no persistence rather than an error. |
storageKey | string | No | Key the mode is persisted under. Defaults to bna-ui.mode. |
defaultMode | 'light' | 'dark' | 'system' | No | Mode used before anything is restored from storage. Defaults to system. |
useModeContext
Returns { mode, setMode, scheme }, or null when no ModeProvider is mounted — which is what lets useColor work without one. scheme is the resolved light/dark value; prefer it over re-deriving from mode. Most apps want useModeToggle instead.
| Prop | Type | Description |
|---|
ModeStorage
type ModeStorage = {
getItem: (key: string) => string | null | Promise<string | null>;
setItem: (key: string, value: string) => void | Promise<void>;
};Deliberately the smallest surface that does the job, and sync-or-async on both
methods, so expo-secure-store (sync getItem/setItem), AsyncStorage
(promise-returning) and MMKV adapters all satisfy it with no shim.
How It Works
The provider keeps the requested mode in state and derives scheme from it —
resolving 'system' against React Native's useColorScheme(). Reading scheme
rather than re-deriving from mode matters: outside system mode there is no
meaningful system value to fall back to.
On native, setMode additionally mirrors the choice into React Native's global
Appearance, so UI that sits above this provider follows too — the status
bar, the Android navigation bar, native sheet presentation, and root layouts
that read the OS scheme to color system chrome.
That call is feature-detected. Appearance.setColorScheme landed in React
Native 0.73 and react-native-web has never implemented it — on web Appearance
is read-only, offering getColorScheme and addChangeListener and no setter.
The context is what drives the theme there, which is why the toggle works on
web at all.
Why Not Local State
The mode used to live in useModeToggle as local useState next to a global
Appearance.setColorScheme call. Half the state was per-component and half was
app-wide, which broke in three ways:
- On web, nothing happened. There was no setter to write the override through, so pressing the toggle changed the icon and left every color alone.
- Remounting reset the cycle. Navigate away from a screen holding the
toggle and back, and
modereturned to'system'while the app stayed dark — so the next press jumped to the wrong mode. - Two toggles disagreed. Each call site had its own independent
mode.
Moving the state into context fixes all three, and makes persistence a single prop rather than a hook every app has to write for itself.
Related
useModeToggle- Cycles light → dark → systemThemeProvider- Mounts this provider for youuseColorScheme- Reads the resolved schemeModeToggle- The animated toggle button