Skip to content
AM
Back to Blog

No-Flash Dark Mode in Next.js

Abdul MoizAuthor
2 min read

A theme toggle that never flashes the wrong colours on load, using one inline script, a data attribute, and useSyncExternalStore instead of a theme library.


Almost every dark-mode implementation I've seen has the same bug: you refresh the page and, for a split second, it flashes white before snapping to dark. It's small, but it makes a site feel cheap.

This portfolio supports light and dark themes and four accent colours, and none of them flash. Here's the whole setup.

Why the Flash Happens

The server has no idea what theme you picked, because your preference lives in localStorage, which only exists in the browser. So the server renders a default. Then React hydrates, a useEffect reads storage, and the theme changes, after the first paint.

Moving that logic into a Client Component doesn't help. It still runs too late.

Fix: Decide Before the First Paint

A plain <script> in the <head> runs synchronously while the HTML is parsed, before anything is painted. That's the one place where you can safely read storage and set the theme:

ts
export const THEME_SCRIPT = `(function(){try{
  var d=document.documentElement;
  var t=localStorage.getItem("theme");
  if(t!=="light"&&t!=="dark")
    t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";
  d.dataset.theme=t;
  var a=localStorage.getItem("accent");
  if(a)d.dataset.accent=a;
}catch(e){}})();`;

In the root layout:

tsx
<html lang="en" data-theme="light" data-accent="blue" suppressHydrationWarning>
  <head>
    <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
  </head>
  ...
</html>

suppressHydrationWarning is essential here. The script deliberately changes the attributes before React hydrates, and this tells React to accept what's in the DOM instead of complaining.

One gotcha: don't export that string from a "use client" file. A Server Component importing from a client module gets a client reference, not the string. Keep it in a plain module.

Theming With CSS Variables

Every colour in the design is a CSS variable. The data attributes just swap them:

css
:root {
  --bg: #fafbfc;
  --fg: #0f172a;
  --accent: var(--accent-l);
}

[data-accent="violet"] {
  --accent-l: #7c3aed;
  --accent-d: #a78bfa;
}

[data-theme="dark"] {
  --bg: #080b12;
  --fg: #e7ecf3;
  --accent: var(--accent-d);
}

Each accent defines a light and a dark shade, and the theme picks one. With Tailwind v4, those variables become utilities (bg-accent, text-muted), and a custom variant makes dark: follow the attribute instead of the OS setting:

css
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

Reading the Theme in React

The toggle button needs to know the current theme. Rather than duplicating it in React state, I treat the <html> attribute as the source of truth and subscribe to it:

ts
function subscribe(onChange: () => void) {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["data-theme", "data-accent"],
  });
  return () => observer.disconnect();
}

const theme = useSyncExternalStore(
  subscribe,
  () => document.documentElement.dataset.theme as Theme,
  () => "light", // server snapshot
);

useSyncExternalStore handles the server/client handoff cleanly: during hydration it uses the server snapshot, then immediately re-renders with the real value. There's no hydration error and no stale state, and every component that reads the theme stays in sync.

The Result

  • Zero flash on hard reloads, in any theme or accent.
  • No dependency. It's about 60 lines total.
  • Respects the OS preference until you pick one explicitly.

Sometimes the best fix is a boring old inline script.

  • #nextjs
  • #react
  • #css
  • #webdev
Abdul Moiz

Written by

Abdul Moiz

Full-Stack Developer in Islamabad, Pakistan, building with React · Node.js · .NET.