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.

src/app/Providers.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.

src/app/global.css
@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 Theme Overrides

The built-in dark theme. Add .dark to any ancestor — tokens flip automatically.

.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);
}

Notes

  • Ghost, ghost-hover, and hover tokens use relative color syntax — they auto-adapt to dark mode with no extra overrides.
  • Use suppressHydrationWarning on <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.