yv
← Articles
frontendnextjs

Dark mode with Next.js — a sane approach.

No flash. No hydration mismatch. Just CSS variables and one data attribute.


Most dark mode implementations I've seen fall into one of two failure modes: a white flash on load, or a React hydration mismatch that spams the console. Both are caused by the same root problem—trying to read user preference in JavaScript before the browser has painted anything.

Here's the approach I landed on for this site. It doesn't use next-themes, it doesn't use a context provider, and it doesn't touch useEffect for anything critical.

The core idea

Put every color in a CSS custom property. Toggle a single [data-theme="dark"] attribute on <html>. Done.

/* globals.css */
:root {
  --paper-0: #FBF8F3;
  --ink-0:   #111210;
  --flag:    #0E7C86;
}

[data-theme="dark"] {
  --paper-0: #16140F;
  --ink-0:   #F2ECDF;
  --flag:    #26B0B8;
}

Every component reads from these variables—nothing hardcodes a color. When the attribute flips, everything updates in one paint. No component re-renders, no prop drilling, no context.

Wiring it into Tailwind

Tailwind doesn't know about CSS variables by default. The trick is to map your color tokens to the variables in tailwind.config.ts:

theme: {
  extend: {
    colors: {
      "paper-0": "var(--paper-0)",
      "ink-0":   "var(--ink-0)",
      "flag":    "var(--flag)",
    },
  },
}

Now bg-paper-0 resolves to background-color: var(--paper-0). When [data-theme="dark"] toggles the variable, every Tailwind class using it updates automatically—no dark: variants needed anywhere.

The toggle component

"use client";
import { useEffect, useState } from "react";

export function ThemeToggle() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    const saved = localStorage.getItem("theme");
    if (saved === "dark") {
      document.documentElement.setAttribute("data-theme", "dark");
      setDark(true);
    }
  }, []);

  const toggle = () => {
    const html = document.documentElement;
    html.classList.add("theme-transitioning");
    setTimeout(() => html.classList.remove("theme-transitioning"), 300);

    const next = !dark;
    setDark(next);
    if (next) {
      html.setAttribute("data-theme", "dark");
      localStorage.setItem("theme", "dark");
    } else {
      html.removeAttribute("data-theme");
      localStorage.setItem("theme", "light");
    }
  };

  return (
    <button onClick={toggle} aria-label={dark ? "Switch to light" : "Switch to dark"}>
      {dark ? "Light" : "Dark"}
    </button>
  );
}

A few things worth noting here.

useState(false) is the default—light mode. On the first render (server and client), the component renders identically, so there's no hydration mismatch. The useEffect runs after hydration and syncs to whatever's in localStorage. If the user had dark mode saved, there will be a brief flash of light before the effect fires—more on that below.

The theme-transitioning class is what makes the color change feel smooth rather than a hard cut:

html.theme-transitioning,
html.theme-transitioning * {
  transition:
    background-color 240ms ease,
    color 240ms ease,
    border-color 240ms ease !important;
}

Adding it only during the toggle means normal interactions (hover states, focus rings) aren't slowed down by transitions they don't need.

The flash problem

The brief flash of light mode before useEffect fires is real. For most users it's imperceptible—the effect runs in milliseconds. But if you want zero flash, the only reliable solution is an inline <script> in <head> that reads localStorage and sets data-theme before the first paint:

// app/layout.tsx
<head>
  <script dangerouslySetInnerHTML={{ __html: `
    try {
      const t = localStorage.getItem('theme');
      if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
    } catch(e) {}
  `}} />
</head>

I didn't add this to the site because the flash is genuinely not noticeable in practice, and I didn't want an inline script in the head. Your call.

What I'd do differently with next-themes

If this were a content site with dozens of themed components, I'd use next-themes. It handles the flash script, the hydration dance, and system preference matching out of the box. The approach above is worth understanding—it's what next-themes does under the hood—but for production use, the library is one less thing to maintain.

The palette

One thing that made this easier: both palettes are warm. The light theme uses paper (#FBF8F3) and near-black ink (#111210). The dark theme uses warm charcoal (#16140F) and warm ivory (#F2ECDF). Switching between them feels natural because the temperature stays consistent—you're not going from a warm beige to a cold dark gray.

The accent color (--flag) lifts from a deep teal (#0E7C86) in light to a brighter teal (#26B0B8) in dark, because the darker background needs more contrast to keep the same visual weight.

That's the whole thing. One attribute, one CSS block, one small component. It's boring in the best way.