ThemingBeginner~1 hour

Add dark mode

Users expect to be able to switch between light and dark themes, and to have the app respect their operating system preference by default. Done wrong it flashes the wrong colors on load and leaves half the UI stuck in the old theme.

the approach

Stop thinking of dark mode as "a dark stylesheet" and think of it as a theming system with three moving parts: (1) semantic color tokens defined as CSS variables that flip based on a class/attribute on the `<html>` element, (2) a source of truth for the current theme that respects the OS `prefers-color-scheme` by default but lets the user override and persists that choice, and (3) a no-flash guarantee so the correct theme is applied before the first paint. On the common 2026 stack (Next.js App Router + Tailwind + shadcn/ui) you do not hand-roll any of this — `next-themes` gives you 1, 2, and 3 for free. Your real work is defining good tokens and never hardcoding a raw color again.

Step by step

  1. 1

    Define semantic color tokens, not colors

    In your global CSS, declare variables like --background, --foreground, --card, --border under :root (light) and re-declare them under .dark. Reference them everywhere via Tailwind's bg-background / text-foreground. If you use shadcn/ui, this token set already exists — don't reinvent it.

  2. 2

    Turn on the class strategy

    Dark mode must be driven by a .dark class on <html>, not by prefers-color-scheme alone, or the user can never override the OS. In Tailwind v4 add `@custom-variant dark (&:where(.dark, .dark *));` to your CSS so the dark: variant follows the class. (Tailwind v3: set darkMode: 'class' in tailwind.config.)

  3. 3

    Mount next-themes in the root layout

    Install next-themes, wrap your app in <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>, and add suppressHydrationWarning to the <html> tag. That attribute is required — the theme class is injected client-side, so React would otherwise flag a mismatch.

  4. 4

    Build the toggle with useTheme

    In a client component, call useTheme() and switch on resolvedTheme (which collapses 'system' into the actual light/dark value). Give the button an aria-label. If you show a per-state icon at the top level, guard with a mounted check to avoid a hydration mismatch on first render.

  5. 5

    Verify there's no flash

    next-themes injects a blocking inline script that sets the class before paint, so a hard refresh should never flash the wrong theme. Confirm this in an incognito window with your OS set to dark — a flash of white means your provider isn't wrapping the layout or you rolled your own without the pre-hydration script.

  6. 6

    Hunt down hardcoded colors

    Grep the codebase for bg-white, text-black, and literal hexes like #fff. Every one of these is a spot that won't respond to the theme. Replace with semantic tokens (bg-background, text-foreground, border-border). This is where 90% of 'dark mode looks broken' bugs live.

  7. 7

    Sanity-check contrast in both themes

    Walk the app in light, dark, and system. Muted text on muted backgrounds is the usual failure — aim for WCAG AA (4.5:1 for body text). Don't forget form inputs, disabled states, focus rings, and any images/logos that assumed a white background.

Starter snippet

tsx
// 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>
  );
}

// components/theme-toggle.tsx
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

export function ThemeToggle() {
  const { setTheme, resolvedTheme } = useTheme();
  return (
    <button
      aria-label="Toggle dark mode"
      className="rounded-md p-2 hover:bg-accent"
      onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
    >
      {resolvedTheme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
    </button>
  );
}

✕ Watch out for

  • Flash of the wrong theme (FOUC) on load, caused by setting the theme after hydration instead of before first paint — this is exactly what next-themes' inline script prevents.
  • Hydration mismatch errors from reading the theme during server render; use resolvedTheme in a client component and add suppressHydrationWarning to <html>.
  • Hardcoded colors like bg-white, text-black, or #fff that ignore the theme entirely — every one must become a semantic token.
  • Only supporting light/dark and dropping 'system', or not persisting the choice, so the app fights the user's OS setting on every visit.
  • On Tailwind v4, forgetting the @custom-variant dark declaration, so the dark: variant silently never fires even though the .dark class is on <html>.

✓ Pro tips

  • Tell your AI agent the exact stack up front — 'Next.js App Router + Tailwind v4 + next-themes' — so it wires up the library instead of hand-rolling a React context provider (a common wrong turn). Then verify it did three things: added suppressHydrationWarning to <html>, used attribute="class", and used semantic tokens rather than hardcoded colors.
  • Ask the agent to grep the repo for bg-white, text-black, and hex literals and convert each to a token — agents love to add a toggle and declare victory while leaving half the UI stuck in light mode.
  • Prefer resolvedTheme over theme in your toggle logic — theme can be the literal string 'system', which makes your light/dark switch behave unpredictably.
  • Test in an incognito window with your OS set to dark. Fresh-load with no localStorage is the only reliable way to catch a flash of the wrong theme.