Dark mode with no flash: the data-theme init pattern
· 5 min read · oks-ui team
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) {}
})();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-schememedia 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.localStoragethrows 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 of React's tree — which means React's hydration will notice that attribute exists server-never-rendered-it and, by default, log a mismatch warning. The fix isn't to move the logic into React (that reintroduces the flash) — it's to tell React this one attribute is expected to differ:
<html lang="en" suppressHydrationWarning>
{/* ... */}
</html><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; }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.