All posts
dark-modenext.jscssnext-themes

Dark Mode Done Right in Next.js

Nimesh FonsekaApril 19, 20266 min read
Dark Mode Done Right in Next.js

Dark mode is one of those features that's easy to get 80% right and hard to get fully right. The missing 20% is a flash of the wrong theme on page load, or a toggle that ignores the system preference, or colours that work in dark but break in light. This post covers how Gwan does it and how to replicate the same approach in your Next.js app.

The three problems

  1. Flash of unstyled content (FOUC) — On SSR, the server renders HTML with no knowledge of the user's saved theme. If you apply the theme in useEffect, the page flashes the wrong colour before hydration.
  2. System preferenceprefers-color-scheme: dark should be respected on first visit, before the user has set a preference.
  3. Persistence — The chosen theme should survive page refreshes and navigation.

The solution: next-themes

next-themes solves all three by injecting a blocking script into <head> that reads localStorage and prefers-color-scheme before the page renders, and applies the .dark class synchronously. No flash.

Setup

npm install next-themes
// src/components/theme-provider.tsx
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </NextThemesProvider>
  );
}
// src/app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
 
export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

suppressHydrationWarning on <html> is required because next-themes adds the class server/client mismatch is expected.

The theme toggle

"use client";
 
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
 
  // Avoid hydration mismatch — don't render until mounted
  useEffect(() => setMounted(true), []);
  if (!mounted)
    return <div className="w-8 h-8 rounded-sm bg-border animate-pulse" />;
 
  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      aria-label="Toggle theme"
    >
      {theme === "dark" ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}

The mounted guard is the key insight — useTheme() returns undefined on the server, so you must wait for mount before rendering any theme-dependent UI.

How the CSS does the heavy lifting

Once .dark is on <html>, everything updates via CSS — no JS re-renders:

:root {
  --background: #f8f9f5;
  --foreground: #1c2218;
}
 
.dark {
  --background: #131a10;
  --foreground: #e2eada;
}
@theme {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

All Tailwind utilities (bg-background, text-foreground) resolve to the correct CSS variable value for the active theme. The browser handles the cascade — zero JavaScript colour mapping.

A real-world token system

Two variables is enough to prove the concept, but a production UI needs a full semantic palette — surfaces, borders, interactive states, status colours. Here is the complete token set used by gwan.dev:

/* globals.css */
 
:root {
  --background: #f8f9f5;
  --foreground: #1c2218;
 
  --surface: #ffffff;
  --surface-raised: #eef2e8;
  --surface-overlay: #e3e9da;
 
  --border: #c4cdb8;
  --border-subtle: #d8dfce;
 
  --primary-default: #435240;
  --primary-default-fg: #f4f7f1;
  --primary-muted: #adc09e;
  --primary-muted-fg: #2c3629;
 
  --muted: #e3e9da;
  --muted-fg: #60705a;
 
  --success: #059669;
  --success-bg: #ecfdf5;
  --success-fg: #064e3b;
 
  --danger: #e11d48;
  --danger-bg: #fff1f2;
  --danger-fg: #881337;
 
  --warning: #d97706;
  --warning-bg: #fffbeb;
  --warning-fg: #78350f;
}
 
.dark {
  --background: #131a10;
  --foreground: #e2eada;
 
  --surface: #1c241a;
  --surface-raised: #252e22;
  --surface-overlay: #2d392a;
 
  --border: #3c4d38;
  --border-subtle: #2d392a;
 
  --primary-default: #adc09e;
  --primary-default-fg: #1c2218;
  --primary-muted: #435240;
  --primary-muted-fg: #e2eada;
 
  --muted: #252e22;
  --muted-fg: #9ea593;
 
  --success: #34d399;
  --success-bg: #022c1e;
  --success-fg: #a7f3d0;
 
  --danger: #fb7185;
  --danger-bg: #200a10;
  --danger-fg: #fecdd3;
 
  --warning: #fbbf24;
  --warning-bg: #1c1300;
  --warning-fg: #fde68a;
}
 
@theme {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-surface: var(--surface);
  --color-surface-raised: var(--surface-raised);
  --color-surface-overlay: var(--surface-overlay);
  --color-border: var(--border);
  --color-border-subtle: var(--border-subtle);
  --color-primary-default: var(--primary-default);
  --color-primary-default-fg: var(--primary-default-fg);
  --color-muted: var(--muted);
  --color-muted-fg: var(--muted-fg);
  --color-success: var(--success);
  --color-success-bg: var(--success-bg);
  --color-success-fg: var(--success-fg);
  --color-danger: var(--danger);
  --color-danger-bg: var(--danger-bg);
  --color-danger-fg: var(--danger-fg);
  --color-warning: var(--warning);
  --color-warning-bg: var(--warning-bg);
  --color-warning-fg: var(--warning-fg);
}

A few design decisions worth noting:

  • Semantic, not literal. --surface-raised describes intent, not a colour value. If you rebrand, you change the value in one place.
  • Foreground-background pairs. Each status colour (--success) comes with a background (--success-bg) and a legible foreground (--success-fg). You never have to guess what text colour reads well on a success badge.
  • The dark palette is not just inverted. Background gets darker, but --primary-default flips to the lighter end of the scale so primary buttons remain accessible on dark surfaces.

Swap the hex values for your own brand colours, keep the token names, and every component adapts automatically.

Don't want to write tokens by hand?

The gwan.dev/themes configurator lets you build your entire palette visually — pick your brand colour, tweak light and dark surfaces, adjust status colours, and watch every component update in real time. When you're happy, hit Export and it generates the complete :root, .dark, and @theme CSS block ready to drop into your globals.css. No manual hex juggling required.

Smooth transitions (optional)

disableTransitionOnChange in the provider prevents colour transitions when the theme changes (avoids a flash of transition). If you want a smooth fade:

Remove disableTransitionOnChange and add to your CSS:

*,
*::before,
*::after {
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.1s ease;
}

Apply this carefully — some interactive elements (buttons, inputs) feel laggy with colour transitions on them. Target only layout elements if needed.

System → manual → system

The three-way toggle (system → light → dark → system) gives users the best control:

const themes = ["system", "light", "dark"];
 
const next = themes[(themes.indexOf(theme ?? "system") + 1) % themes.length];
setTheme(next);

Most apps only need light/dark toggle, but if you want the system option exposed, this is the pattern.

What not to do

  • Don't use className={isDark ? "dark-class" : "light-class"} — it won't work with SSR
  • Don't apply theme in useEffect without the mounted guard — you'll get hydration warnings
  • Don't use @media (prefers-color-scheme: dark) in CSS alongside .dark {} — they'll conflict

Summary

ProblemSolution
Flash of wrong themenext-themes blocking script in <head>
System preferencedefaultTheme: "system" + enableSystem
Persistencenext-themes writes to localStorage
Hydration mismatchsuppressHydrationWarning on <html>, mounted guard on toggle
Colour switchingCSS custom properties on .dark class — zero JS

Share this article

LinkedInX / Twitter

Written by

Nimesh Fonseka

More posts →