Why the flash happens

The user's theme preference lives in `localStorage`, which only exists in the browser. Server-rendered HTML therefore cannot know it. If you read the preference in a `useEffect`, that runs after React hydrates, which is after the browser has already painted — so the user sees the default theme first, then a jump to their actual one.

This is not a styling bug and no amount of CSS fixes it. The problem is ordering: the decision is made too late. The only real fix is to apply the theme before the browser paints anything.

The blocking script

`createThemeScript` produces a small synchronous script you inject into the document head. Because it is synchronous and in the head, it runs before the body is painted. It reads the stored preference, falls back to the system setting via `prefers-color-scheme`, and sets the theme attribute on the root element.

By the time the browser paints, the correct theme attribute is already on `<html>` and the correct variables are already in effect. There is no flash because there was never an intermediate state to see.

Wiring it up in the App Router

Render the script inside your root layout's `<head>`. It must be a plain synchronous script — not a deferred one, not a module, and not something React renders after hydration, because all of those run too late by definition.

The library exports `DEFAULT_STORAGE_KEY` and `DEFAULT_THEME_ATTRIBUTE` so the script and the React side agree on where the preference is stored and which attribute carries it. Use the exported constants rather than retyping the strings; a typo here produces exactly the flash you are trying to remove, and it is invisible in code review.

The React side

`ThemeProvider` and the `useTheme` hook handle runtime switching, and `ThemeToggle` is a ready-made control if you do not want to build one. The provider is the only place in the entire library that touches `localStorage`, and that access is wrapped in try/catch — storage throws in some privacy modes and a theme preference is never worth crashing a page over.

Note the division of labour: the blocking script owns the first paint, the provider owns everything after hydration. They are separate because they solve different problems, and conflating them is what produces libraries that either flash or cannot switch at runtime.

Respecting the system setting

The correct default is the system preference, not light. A user who has set their operating system to dark mode has already told you what they want, and showing them a white page is ignoring an explicit signal.

The three-state model is what you want: an explicit light choice, an explicit dark choice, and 'follow the system' as the default. That third state is the one people forget, and its absence means a user who changes their OS theme at sunset sees no change on your site.

Testing it honestly

Throttle your network in devtools and hard-reload with dark mode selected. The flash, if there is one, is far more visible on a slow connection because the gap between paint and hydration is longer. Testing only on a fast local dev server is how flashes ship.

Also test in a private window with storage disabled entirely. The script should fall back to the system preference and the page should render correctly rather than erroring — which is the reason for the try/catch around storage access.

Key takeaways

  • The flash is a rendering-order problem: useEffect runs after paint, so the decision is made too late
  • createThemeScript produces a synchronous head script that applies the theme before first paint
  • Use the exported DEFAULT_STORAGE_KEY and DEFAULT_THEME_ATTRIBUTE so script and React agree — a typo here reintroduces the flash silently
  • The blocking script owns first paint; ThemeProvider owns runtime switching. Separate problems
  • Support three states — light, dark, and follow-the-system — with system as the default
  • Test throttled and in a private window with storage blocked; fast local reloads hide the flash

Conclusion

Preventing the dark-mode flash is entirely about doing the work before the browser paints. A synchronous head script that reads the preference and sets an attribute is the whole solution, and everything else — the provider, the toggle, the hook — is about what happens afterwards.

Enjoyed this article?

Vivek Kumar Singh

Vivek Kumar Singh

Technical Expert · Full Stack Cloud Engineer · Tokyo, Japan