Laurel

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. --background is 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. --primary is 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. --border is a faint hairline; --input is 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.

src/theme.css (excerpt)
: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-themes
app/layout.tsx
import { 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:

index.html
<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:

theme.ts
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.

PairTarget
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.

On this page