Dark mode
A real dark theme — neutral ground, sage held to the primary. Class-based, variable-driven, contrast-checked.
Laurel ships light and dark. Neutrals do the work in both — text, surfaces and borders are a near-neutral ramp — and sage marks the moments: primary fills, selected/active states, focus rings. Dark is not an inversion: the ground moves to a neutral charcoal (never green-tinted, never black), and the primary sits at the sage ramp's mid-tone, the same hue as light, never a pastel. Both themes live in theme.css; components do not know which theme they are in.
Principles
- A neutral ground, never black.
--backgroundis a near-black charcoal, not tinted toward the brand hue. Text is an off-white on the same neutral ramp, never#fff. - Elevation is lighter. Surfaces sit one step above the ground (
--card,--popover) and are drawn with a hairline edge. Shadows stay soft and only deepen; the hairline does the work. - The primary stays put.
--primaryis a mid-tone step of the same sage ramp as light — not lifted to a pastel — so buttons, links and rings read as the same brand in both themes. - Contrast from lightness, not saturation. Chroma stays low everywhere except the sage and status hues.
- Hairline dividers, stronger control boundaries.
--borderis a faint hairline;--inputis a separate, stronger neutral step so form-control edges clear WCAG 1.4.11 (3:1) — see Contrast targets. - Destructive lightens to stay legible.
How it works
theme.css defines two OKLCH ramps — --neutral-* and --sage-*, each with its hue locked — and every shadcn variable maps onto a ramp step, once under :root, .light and once under .dark. Nothing else changes between themes — components read --primary, --card, --border and so on, and the values flip under them.
:root,
.light {
--neutral-900: oklch(0.224 0.004 125);
--sage-700: oklch(0.41 0.068 151.6);
--background: oklch(1 0 0);
--foreground: var(--neutral-900);
--primary: var(--sage-700);
--border: var(--neutral-300);
}
.dark {
--background: var(--neutral-950); /* neutral charcoal, not green-tinted */
--primary: var(--sage-600); /* same hue as light, its mid-tone step */
--border: var(--neutral-700);
}Any container with a dark class re-resolves the variables, not just <html>. A .light class works the other way, as a forced-light island inside a dark page (the docs use both to show the two palettes side by side).
Enable it in an app
The mechanism is shadcn's: a dark class on an ancestor, normally <html>. Three states are worth supporting — light, dark and system (follow prefers-color-scheme).
Next.js — next-themes
npm i next-themesimport { ThemeProvider } from "next-themes"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
)
}next-themes injects a blocking script, so there is no flash of the wrong theme; suppressHydrationWarning on <html> is required because that script sets the class before hydration. Switch themes with const { setTheme } = useTheme() — "light", "dark" or "system".
Vite and everything else — a no-flash script
Put this inline script in <head>, before your stylesheet, so the class is set before first paint:
<script>
(function () {
try {
var stored = localStorage.getItem("theme");
var dark =
stored === "dark" ||
((!stored || stored === "system") && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch (e) {}
})();
</script>And a toggle that writes the same key:
export function setTheme(theme: "light" | "dark" | "system") {
localStorage.setItem("theme", theme);
const dark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
}If you use system mode, also listen to matchMedia("(prefers-color-scheme: dark)") and re-apply while the stored theme is "system".
The dark variant
theme.css registers @custom-variant dark (&:is(.dark *)); so Tailwind's dark: utilities match a .dark ancestor — the shadcn convention. Nothing else to configure.
Use dark: sparingly
Prefer semantic utilities. bg-card, text-muted-foreground and border-border already resolve correctly in both themes, so a dark: prefix is normally unnecessary — and a hard-coded colour with a dark: twin is exactly the drift a theme exists to prevent. Reach for dark: only for a genuinely mode-specific decision, such as a scrim that needs to be heavier on a dark ground (the dialog overlay is bg-black/10 dark:bg-black/50). Stock shadcn components carry a handful of dark: variants; they line up with the variables and need no edits.
Contrast targets
npm run check:contrast reads theme.css for both themes, resolves them to sRGB, and fails the build if a pair drops below its target. It runs as part of npm run build.
| Pair | Target |
|---|---|
| Text on background, card and popover | ≥ 7:1 |
muted-foreground on background, card, popover, muted | ≥ 4.5:1 |
primary-foreground on primary; secondary-foreground on secondary; accent-foreground on accent | ≥ 4.5:1 |
success, warning, info, destructive as text, and their -foreground on the solid fill | ≥ 4.5:1 |
| Primary and the focus ring, as a line or mark, on background and card | ≥ 3:1 |
input (a form control's boundary) on background | ≥ 3:1 — a separate, stronger step from border |
border (a divider) against its ground | ≥ 1.2:1 (a hairline: visible, not loud) |
When you retune a colour in theme.css, run it before you commit.