DEV Community

Cover image for Dark mode with no flash: the data-theme init pattern
omkar sahu
omkar sahu

Posted on Originally published at oks-ui.com on

Dark mode with no flash: the data-theme init pattern

The classic dark-mode bug: a user with dark mode saved loads your page, and for one visible frame it's light — then it snaps to dark. The fix isn't complicated, but it only works if you do it in a very specific order, and most "just add a class in useEffect" implementations get that order wrong. Here's the pattern this site itself runs on, in full.

Why the flash happens at all

Server-rendered HTML has no idea what theme a returning visitor prefers — that preference lives in the browser's localStorage, which the server can't read. So the page necessarily ships with some default theme baked into the markup. If you apply the real theme in a React useEffect, that effect runs after the browser has already painted the default — hence the flash.

The fix: a synchronous script before hydration

The theme has to be applied before the browser paints anything, which means before React even loads. A plain, tiny, synchronous <script> in <head> — or as early in <body> as your framework allows — does exactly that:

(function () {
  try {
    var stored = localStorage.getItem("oks-ui-theme");
    var theme = stored === "light" || stored === "dark"
      ? stored
      : (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
    document.documentElement.setAttribute("data-theme", theme);
  } catch (e) {}
})();
Enter fullscreen mode Exit fullscreen mode

Three things make this work that are easy to get wrong in a rewrite:

  • It runs before hydration, not in an effect. In Next.js that's <Script strategy="beforeInteractive">; in a non-Next app it's a plain inline <script> placed before your stylesheet, since the attribute has to exist before the CSS that reads it is applied.
  • It falls back through three sources in order: an explicit stored choice, then the OS-level prefers-color-scheme media query, then a hard default. Skipping the media-query fallback means a first-time visitor with dark mode set at the OS level still sees a light flash.
  • It's wrapped in try/catch. localStorage throws in some locked-down or privacy-mode browser contexts — a script this early in the page load has no error boundary to catch it, so an uncaught throw here can block the rest of the page from rendering at all. ## The React side: expect the mismatch, don't fight it The script sets data-theme on <html> directly, outside React's tree — which means React's hydration will notice that attribute the server never rendered and, by default, log a mismatch warning. The fix isn't to move the logic into React, which reintroduces the flash — it's to tell React this one attribute is expected to differ:
<html lang="en" suppressHydrationWarning>
  {/* ... */}
</html>
Enter fullscreen mode Exit fullscreen mode

Scope suppressHydrationWarning narrowly. Put it only on the element whose attributes the script actually touches — <html> here. Slapping it on a much larger subtree silences real hydration bugs along with the expected one.

Everything downstream is just CSS

Once data-theme="dark" is on <html>, oks-ui's tokens respond to it with an ordinary attribute selector — no React context, no re-render, no provider:

:root { --oks-color-surface: #fff; }
:root[data-theme="dark"] { --oks-color-surface: #0a0a0a; }
Enter fullscreen mode Exit fullscreen mode

A toggle button just flips the attribute and writes the same key back to localStorage — no re-run of the init script needed, since the browser already has the page open.
See the full token list this responds to at Dark mode.


Originally published at oks-ui.com.

Top comments (0)