Dark Mode
How dark mode works, provider setup, and building a CSS-only toggle.
How It Works
Dark mode is handled by next-themes, which toggles the .dark class on <html>. All color tokens flip automatically — no extra classes needed.
Provider Setup
Create src/app/Providers.tsx and wrap your app in ThemeProvider from next-themes, then import it into your root layout.tsx.
import { ThemeProvider } from "next-themes";
<ThemeProvider
attribute="class" // toggles .dark class
defaultTheme="system" // respects OS preference
enableSystem // this is the default, but I included it to save you from confusion. You're welcome 💁♂️
disableTransitionOnChange
>
{children}
</ThemeProvider>Tailwind Variant
Tailwind v4's dark: variant defaults to the prefers-color-scheme media query. Register a custom variant in global.css so dark: utilities respond to the .dark class instead.
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));Building a Toggle
Render both icons in the DOM and let Tailwind's dark: variant show the right one. No useTheme read in render means no hydration mismatch to worry about.
import { useTheme } from "next-themes";
import { IconButton } from "@/ui";
import { DarkModeIcon, LightModeIcon } from "@/ui/icons";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
return (
<IconButton
variant="ghost"
size="sm"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<span className="relative inline-flex size-[1em] items-center justify-center">
<LightModeIcon className="absolute rotate-0 scale-100 transition-transform duration-200 dark:-rotate-90 dark:scale-0" />
<DarkModeIcon className="absolute rotate-90 scale-0 transition-transform duration-200 dark:rotate-0 dark:scale-100" />
</span>
</IconButton>
);
}Both icons are always in the DOM. The .dark class on <html> (set by next-themes before React hydrates) drives which one is visible via dark: utilities. resolvedTheme is only read inside onClick, which fires after hydration — no mismatch possible.
.dark {
--color-root: var(--dark);
--color-root-contrast: var(--light);
--color-root-subtle: oklch(0.35 0.008 65);
--color-root-contrast-subtle: oklch(0.68 0.01 72);
--color-root-100: oklch(0.22 0.01 65);
--color-root-100-contrast: var(--light);
--color-root-100-subtle: oklch(0.38 0.01 65);
--color-root-100-contrast-subtle: oklch(0.7 0.01 72);
--color-root-200: oklch(0.3 0.01 65);
--color-root-200-contrast: var(--light);
--color-root-200-subtle: oklch(0.45 0.012 65);
--color-root-200-contrast-subtle: oklch(0.74 0.01 72);
--color-primary: oklch(0.85 0.07 55);
--color-primary-contrast: var(--dark);
--color-primary-subtle: oklch(0.7 0.04 55);
--color-primary-contrast-subtle: oklch(0.38 0.03 55);
--color-secondary: oklch(0.85 0.05 139);
--color-secondary-contrast: var(--dark);
--color-secondary-subtle: oklch(0.7 0.03 139);
--color-secondary-contrast-subtle: oklch(0.38 0.02 139);
--color-info: oklch(0.24 0.03 75);
--color-info-contrast: oklch(0.78 0.06 75);
--color-progress: oklch(0.24 0.06 250);
--color-progress-contrast: oklch(0.78 0.14 250);
--color-success: oklch(0.24 0.06 145);
--color-success-contrast: oklch(0.8 0.16 145);
--color-warning: oklch(0.26 0.07 63);
--color-warning-contrast: oklch(0.88 0.14 63);
--color-danger: oklch(0.24 0.07 25);
--color-danger-contrast: oklch(0.78 0.18 25);
}- Ghost, ghost-hover, and hover tokens use relative color syntax — they auto-adapt to dark mode with no extra overrides.
- Use
suppressHydrationWarningon<html>— next-themes injects a script that sets the class before React hydrates. - Scoped dark sections work too — add
className="dark"to any element to flip all tokens inside it. - Apply the same stacked-icon pattern to swap logos, images, or any other theme-dependent asset — render both, let
dark:utilities handle visibility.