Dark Mode
The library supports automatic dark mode with system preference detection.
Theme Modes
| Mode | Behavior |
|---|---|
'system' | Follows device settings (default) |
'light' | Always light mode |
'dark' | Always dark mode |
Changing Theme Mode
import { useTheme } from '@multinaire/expo-ui';
function ThemeToggle() {
const { themeMode, changeThemeMode } = useTheme();
return (
<View>
<Text>Current: {themeMode}</Text>
<Button title="Light" onPress={() => changeThemeMode('light')} />
<Button title="Dark" onPress={() => changeThemeMode('dark')} />
<Button title="System" onPress={() => changeThemeMode('system')} />
</View>
);
}
Getting Current Theme
Use useThemeMode() to get the resolved theme (accounts for system preference):
import { useThemeMode } from '@multinaire/expo-ui';
function MyComponent() {
const resolvedTheme = useThemeMode();
// Returns 'light' or 'dark' (never 'system')
return (
<Icon
icon={resolvedTheme === 'dark' ? 'Sun' : 'Moon'}
/>
);
}
Theme Picker Component
import {
ListPicker,
useTheme,
} from '@multinaire/expo-ui';
const themeOptions = [
{ text: 'System', value: 'system' },
{ text: 'Light', value: 'light' },
{ text: 'Dark', value: 'dark' },
];
function ThemePicker() {
const { themeMode, changeThemeMode } = useTheme();
return (
<ListPicker
selectedItem={themeMode}
items={themeOptions}
onChange={changeThemeMode}
/>
);
}
Persistence
Theme preference is automatically saved to AsyncStorage. When the app restarts, the user's preference is restored.
Status Bar
The status bar color automatically updates based on the theme. The library uses expo-system-ui to set the background color.
Browser Chrome (Web)
On web, MultinaireUI also keeps the browser's own surfaces on the app's
resolved mode — no setup required:
<meta name="theme-color">, which Safari uses to tint its top bar and bottom toolbar.color-schemeand thehtml/bodybackground, which paint the over-scroll (rubber-band) areas above and below the document, and tell the browser which scheme to render its own controls and scrollbars in.
This matters once a user picks an explicit 'light' or 'dark' mode: without
it the browser keeps following the OS preference, so a light bar and footer
end up wrapped around a dark app (and vice versa).
The update runs in a layout effect, so it lands before the first paint — nothing flashes.
Covering the first paint
The app's own theme is only known after React mounts. To keep the pre-hydration
frame from painting the browser's default white, express the theme statically
in app/+html.tsx, keyed on prefers-color-scheme:
import { ScrollViewStyleReset } from 'expo-router/html';
import type { PropsWithChildren } from 'react';
import theme from '@/src/assets/theme.json';
export default function Root({ children }: PropsWithChildren) {
const { light, dark } = theme.colors;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content={light.background}
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content={dark.background}
/>
<ScrollViewStyleReset />
<style
dangerouslySetInnerHTML={{
__html: `
:root { color-scheme: light; background-color: ${theme.colors.light.background}; }
body { background-color: ${theme.colors.light.background}; }
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; background-color: ${theme.colors.dark.background}; }
body { background-color: ${theme.colors.dark.background}; }
}`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
The Expo template ships this file already.
Best Practices
- Design for both modes - Test your UI in both light and dark
- Use semantic colors -
onBackgroundinstead of hardcoded black/white - Avoid pure black - Use
#121212or similar for better readability - Check contrast ratios - Ensure text is readable in both modes
- Test images - Some images may need different versions for each mode