Add dark mode and a Light / Dark / System mode toggle to your Expo app — for NativeWind and Uniwind.
lvcn themes ship with full light and dark token sets. This guide adds a persistent, shadcn-style theme provider and a mode toggle (Light / Dark / System) to your app. Choose your style engine below.
Projects scaffolded with lovda init already include light and dark CSS variables and the dark mode wiring. In that case you only need the provider and toggle from the steps below.
Enable class-based dark mode.
NativeWind switches the dark: variant from a class, so set darkMode: "class" in your Tailwind config:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./src/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
// ...
};Then make sure global.css defines your tokens for light (:root) and dark (.dark:root). NativeWind uses HSL values:
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
/* ...remaining tokens... */
}
.dark:root {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
/* ...remaining tokens... */
}See Theming for the complete token list.
Install the persistence dependency.
The chosen theme is stored so it survives app restarts.
npx expo install @react-native-async-storage/async-storageCreate a theme provider.
import AsyncStorage from "@react-native-async-storage/async-storage"
import * as React from "react"
import { useColorScheme } from "nativewind"
type Theme = "light" | "dark" | "system"
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const ThemeProviderContext = React.createContext<ThemeProviderState>({
theme: "system",
setTheme: () => null,
})
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "lvcn-ui-theme",
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const { setColorScheme } = useColorScheme()
const [theme, setThemeState] = React.useState<Theme>(defaultTheme)
// Restore the saved preference on launch.
React.useEffect(() => {
AsyncStorage.getItem(storageKey).then((value) => {
const next = (value as Theme | null) ?? defaultTheme
setThemeState(next)
setColorScheme(next)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const value = React.useMemo(
() => ({
theme,
setTheme: (next: Theme) => {
setThemeState(next)
setColorScheme(next) // drives NativeWind's `dark:` variant
AsyncStorage.setItem(storageKey, next)
},
}),
[theme, setColorScheme, storageKey]
)
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export function useTheme() {
return React.useContext(ThemeProviderContext)
}Wrap your root layout.
import "../global.css"
import { Stack } from "expo-router"
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout() {
return (
<ThemeProvider defaultTheme="system">
<Stack screenOptions={{ headerShown: false }} />
</ThemeProvider>
)
}Add a mode toggle.
The toggle reuses lvcn's own components. Add them first:
npx lovdacn@latest add button dropdown-menu icon textimport { Moon, Sun } from "lucide-react-native"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Icon
as={Sun}
className="size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Icon
as={Moon}
className="absolute size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onPress={() => setTheme("light")}>
<Text>Light</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => setTheme("dark")}>
<Text>Dark</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => setTheme("system")}>
<Text>System</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}Use it.
Drop <ModeToggle /> anywhere inside the provider — a screen header, tab bar, or settings screen:
import { ModeToggle } from "@/components/mode-toggle"
<ModeToggle />How it works
- System follows the device appearance and keeps tracking it as the user changes their OS setting.
- Light / Dark override the device and lock the app to that appearance. On NativeWind,
setColorSchemetoggles thedarkclass that powersdark:utilities; on Uniwind,Uniwind.setThemeswitches the active theme. - The selection is persisted with AsyncStorage and re-applied on the next launch, so the toggle behaves like shadcn/ui on the web.
Use semantic tokens
For components to adapt automatically, style them with semantic classes instead of hard-coded colors:
import { View } from "react-native"
import { Text } from "@/components/ui/text"
export function Panel() {
return (
<View className="bg-background border-border rounded-lg border p-4">
<Text className="text-foreground">Adapts to light and dark</Text>
</View>
)
}This documentation site
For reference, this site (Next.js) uses next-themes with the class strategy — use the sun / moon control in the header to preview tokens:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>Checklist
- Light and dark values exist for every token
- The root layout is wrapped in
ThemeProvider - Components use semantic classes (
bg-background,text-foreground,border-border)
Related
- Theming — tokens, style packs, and base colors
- Installation — full setup guide
- Expo Router — file-based navigation setup