Dark Mode
Leave Localhost supports light mode, dark mode, and system-preference detection out of the box. The implementation uses next-themes on the app side and CSS custom properties on the design-token side.
Leave Localhost supports light mode, dark mode, and system-preference detection out of the box. The implementation uses next-themes on the app side and CSS custom properties on the design-token side.
How It Works
1. Theme Provider
The root layout at apps/app/src/app/[locale]/layout.tsx wraps the entire app
in a ThemeProvider from next-themes:
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>| Prop | Effect |
|---|---|
attribute="class" | Toggles a dark class on the <html> element |
defaultTheme="system" | Respects prefers-color-scheme on first visit |
enableSystem | Keeps reacting to OS-level theme changes |
disableTransitionOnChange | Prevents flash-of-transition during toggle |
2. CSS Custom Properties
Design tokens in packages/ui/src/globals.css are defined twice — once in
:root (light) and once in .dark (dark). When next-themes adds the dark
class to <html>, the .dark selector activates and every token swaps:
:root {
--background: oklch(1 0 0); /* white */
--foreground: oklch(0.32 0 0); /* dark gray */
--card: oklch(1 0 0);
/* ... */
}
.dark {
--background: oklch(0.2 0 0); /* near-black */
--foreground: oklch(0.92 0 0); /* light gray */
--card: oklch(0.27 0 0);
/* ... */
}Components reference tokens like bg-background and text-foreground, so they
automatically adapt without any component-level dark-mode logic.
3. Tailwind Integration
Tailwind CSS 4 is configured with a custom dark variant in globals.css:
@custom-variant dark (&:is(.dark *));This tells Tailwind that the dark: prefix should match any element inside a
.dark ancestor, aligning with the attribute="class" strategy used by
next-themes.
Theme Switcher
Users toggle themes from the user menu in the sidebar footer. The
ThemeMenuSub component at
apps/app/src/app/[locale]/(dashboard)/_components/theme-switcher.tsx renders a
dropdown sub-menu with radio items:
const { theme: currentTheme, setTheme, themes } = useTheme();
<DropdownMenuRadioGroup
value={currentTheme}
onValueChange={(theme) => setTheme(theme)}
>
{themes.map((theme) => (
<DropdownMenuRadioItem key={theme} value={theme}>
{formatTheme(theme)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>;Three options are available:
| Option | Behavior |
|---|---|
| Light | Forces light mode |
| Dark | Forces dark mode |
| System | Follows OS preference |
The selected theme is persisted in localStorage by next-themes and restored
on subsequent visits.
Preventing Flash of Incorrect Theme
The combination of suppressHydrationWarning on <html> and <body> plus
next-themes' inline script ensures the correct theme class is applied before
the first paint. This eliminates the "white flash" that class-based dark mode
implementations can produce with SSR.
Adding Dark Mode to New Tokens
When you add a new CSS custom property:
- Define the light value in the
:rootblock. - Define the dark value in the
.darkblock. - Map it to a Tailwind token in the
@theme inlineblock if you want a utility class (e.g.--color-my-token: var(--my-token)).
:root {
--success: oklch(0.65 0.2 145);
--success-foreground: oklch(1 0 0);
}
.dark {
--success: oklch(0.55 0.18 145);
--success-foreground: oklch(1 0 0);
}
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}Now bg-success and text-success-foreground work in both modes.
Using dark: in Component Code
For one-off dark-mode adjustments that don't warrant a new token, use the
dark: Tailwind prefix directly:
<div className="border-input dark:border-input dark:bg-input/30" />Prefer tokens for anything systematic. Reserve dark: overrides for small
tweaks within individual components.
Marketing Site
apps/marketing has its own theme and ships with dark mode enabled. It wraps
its root layout in a next-themes ThemeProvider (defaulting to dark) and
exposes a mode toggle in the header. Its palette lives in
apps/marketing/src/app/theme.css, which overrides the shared semantic tokens
for the marketing site only — see the marketing app's README for swapping the
theme with a single shadcn command.
Next Reads
- Theming — the full design token reference.
- Customizing the Dashboard — modifying the theme switcher location or behavior.
Theming
Leave Localhost uses CSS custom properties for all design tokens. Colors are defined in the OKLCH color space for perceptual uniformity, and every token is referenced through Tailwind CSS 4's @theme block so you can use them as regular Tailwind utilities.
Customizing the Dashboard
The dashboard is the authenticated shell that signed-in users see. It includes a collapsible sidebar, a contextual header, an organization switcher, and a user menu. All of these are customizable.