← Writing
Jan 2026

Shipping dark mode without the flash of wrong theme

You have seen the bug even if you have never filed it: a statically-rendered page loads in light mode, and one frame later — after React wakes up and reads localStorage — it slams into dark. The flash of wrong theme. It is the theming equivalent of a flash of unstyled content, and on a portfolio that claims design-system expertise, it is a resignation letter.

Why SSR cannot save you

The server renders your HTML long before it knows anything about the visitor. The theme preference lives in the visitor’s localStorage; localStorage lives in the browser; the browser has not been consulted yet. Any solution that waits for your framework to hydrate is already too late, because the browser paints server HTML the moment it has it. The fix has to run before first paint, which means it cannot be a React effect. It has to be a blocking script in the head.

The blocking script

Blocking scripts have a deservedly bad reputation, so keep it tiny and inline — no network fetch, just a synchronous read and an attribute write:

try {
  var t = localStorage.getItem('gc-theme')
  if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark' : 'light'
  document.documentElement.dataset.theme = t
} catch (e) {}

It runs after the html element exists but before anything paints, so the attribute is in place for the very first frame. The try/catch is not paranoia: localStorage throws in some private-browsing modes, and a broken theme script that takes the page down with it is a worse bug than the flash ever was.

server HTMLarrivesinline script setsdata-themefirst paint —correct themehydration —nothing to fix
Fig. 1 — the whole trick is where the script sits: after the HTML exists, before the browser paints.

In Next.js this goes into the root layout via dangerouslySetInnerHTML, plus suppressHydrationWarning on the html element — the server genuinely did not know the attribute value, the client genuinely did, and that one-attribute disagreement is the whole design, not a bug to silence elsewhere.

The script is nothing without the contract

Note what the script does not do: it does not set any colours. It flips one attribute, and the token layer does the rest — [data-theme="dark"] re-points the semantic tokens, and every component repaints because components only ever read tokens. If your components hold literal colours, no head script can save you; you would need to rewrite styles at runtime, which is exactly the after-first-paint work that causes the flash.

Finish with two lines of politeness: set color-scheme in CSS for each theme so native scrollbars and form controls match, and you are done. First paint correct, every paint after it correct, and nothing in the bundle to maintain beyond six lines of vanilla JavaScript and one attribute selector.