Leave Localhost logoLeave LocalhostDocs
UI

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>
PropEffect
attribute="class"Toggles a dark class on the <html> element
defaultTheme="system"Respects prefers-color-scheme on first visit
enableSystemKeeps reacting to OS-level theme changes
disableTransitionOnChangePrevents 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:

OptionBehavior
LightForces light mode
DarkForces dark mode
SystemFollows 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:

  1. Define the light value in the :root block.
  2. Define the dark value in the .dark block.
  3. Map it to a Tailwind token in the @theme inline block 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

On this page