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.
Visual Baseline
How to run the Playwright visual, route-smoke, and persona checks.
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.