Dark mode
Class-driven, token-powered, and free per component.
How it works
Components never know which theme they're in — they read semantic tokens, and the .dark class swaps the token values.
:root { --background: #ffffff; --foreground: #171717; }
.dark { --background: #1d1d1d; --foreground: #ffffff; }
/* a component just says: */
<div className="bg-background text-foreground" />Because the swap happens at the token layer, adding a component never adds dark-mode work. There is no dark:bg-… scattered through component code to keep in sync.
Toggling
The Providers wrapper mounts next-themes; useTheme() flips the class.
"use client";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<Button
variant="outline"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
Toggle theme
</Button>
);
}The details that usually get missed
color-schemeis declared per theme, so native controls— date-picker glyphs, file inputs, scrollbars, selection highlights — follow along. CSS can't reach inside a date input; this is the only correct signal.suppressHydrationWarningon<html>is required — the theme class is applied before React hydrates.- Dark elevation is surface lift, not heavier shadows: each level up is a lighter background token. Shadows carry little signal on dark surfaces.
- The
dark:Tailwind variant resolves against the class (via@custom-variant), not the OS setting — so the user's in-app choice always wins.
Verify all four modes
Light/dark is half the matrix — the header toggles exist so every change is checked in light/dark and LTR/RTL before it ships.