How to Add Dark Mode Without the Flash of Wrong Theme
Dark mode is a solved problem with one genuinely tricky part: the flash. If the theme is applied by JavaScript after hydration, every dark-mode user sees a white page for a few hundred milliseconds on every navigation. It's the detail that separates implementations that feel polished from ones that feel broken, and it's the reason this needs more than a CSS toggle.
The second thing people get wrong is treating the theme as a boolean. It has three states — light, dark, and follow the system — and collapsing it to two means a user who has chosen "system" gets stuck on whatever they last toggled.
Tokens, not colors; three states, not two
Two decisions determine whether this stays maintainable: every color goes through a semantic token, and the theme is a tri-state preference rather than a flag.
- ›Define semantic tokens (`--surface`, `--text-muted`, `--border`), not literal colors, and never write a hex value in a component.
- ›Store `light | dark | system` — three states. `system` means follow `prefers-color-scheme` live, including when the OS switches at sunset.
- ›Apply the theme in a blocking inline script before first paint. This is the only way to avoid the flash.
- ›Don't naively invert. Dark surfaces need lower saturation and different contrast ratios; pure white text on pure black is uncomfortable to read.
- ›Set `color-scheme` on the root so native form controls, scrollbars, and the browser's own UI match.
The approaches
CSS custom properties with a class or data attribute
// best for: Essentially every application. This is the standard approach.
- React
- Vue.js
- Svelte
- Angular
- Next.js
- Nuxt
- SvelteKit
- Laravel
Declare tokens on `:root` for light, override them under a `.dark` class or `[data-theme="dark"]` selector, and reference tokens everywhere. Switching themes is one class on `<html>`, and every color follows because nothing references a literal.
The part that requires care is the three-state logic. The `:root` block holds the light palette. A `@media (prefers-color-scheme: dark)` block, guarded so an explicit light choice still wins, handles the system state. And an explicit `[data-theme="dark"]` block handles the manual choice. Miss the guard and a user who picked light on a dark-OS machine gets dark anyway.
Add `color-scheme: light dark` so native widgets — scrollbars, date pickers, form controls — follow along. Without it you get dark surfaces with a glaring white scrollbar.
:root { /* light palette, always defined */
color-scheme: light;
--surface: #ffffff;
--text: #12181f;
}
/* System dark — but an explicit light choice must still win. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface: #12181f;
--text: #e3e9ef;
}
}
/* Explicit dark choice beats a light OS. */
:root[data-theme="dark"] {
color-scheme: dark;
--surface: #12181f;
--text: #e3e9ef;
}The gotcha
Defining a color *only* inside the dark block. It then has no value in the light and system states, so the property falls back to `inherit` or the initial value — usually producing dark text on a dark background. Every token must have a value on bare `:root`.
The blocking script that prevents the flash
// best for: Any server-rendered or statically generated site, which is where the flash occurs.
- Next.js
- Nuxt
- SvelteKit
- Django
- Laravel
- Ruby on Rails
The flash happens because the server doesn't know the user's theme — it's in `localStorage`, which only the browser can read. So the HTML ships with the default theme and JavaScript corrects it after hydration, which is several hundred milliseconds of white.
The fix is a small synchronous inline script in `<head>`, before any stylesheet, that reads `localStorage` and sets the attribute on `<html>` immediately. It must be inline and must not be `async` or `defer` — the entire point is that it blocks rendering for the microsecond it takes to run.
It's genuinely one of the few places where a blocking script is correct. Ship it before the CSS and the page paints in the right theme, first frame.
<!-- In <head>, before stylesheets. Inline and blocking on purpose:
deferring it is exactly what causes the flash. -->
<script>
(function () {
try {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" &&
matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {
/* private mode can throw on localStorage — fall through to light */
}
})();
</script>The gotcha
`localStorage` throws in some privacy modes and embedded webviews rather than returning null. An unguarded read throws before the page renders, and because it's a blocking script in the head, the whole page stays blank. Always wrap it in try/catch.
Framework and no-code theme systems
// best for: Teams who'd rather not hand-roll the state and persistence layer.
- Next.js
- React
- Vue.js
- Webflow
- Framer
- Bubble
- Retool
`next-themes` is the de facto standard in the React world and handles the tri-state logic, persistence, the blocking script, and cross-tab synchronization. Vue and Svelte have equivalents. Using one is the right call — the edge cases it covers are exactly the ones that produce bug reports.
Cross-tab sync is the detail worth calling out: without listening to the `storage` event, changing the theme in one tab leaves others stale until reload. It's minor but noticeable.
In no-code tools, Webflow and Framer both have native variable modes that map cleanly onto this model. Bubble and Retool generally require conditional styling, which works but doesn't scale past a handful of elements — there, defining a small set of styles centrally and referencing them is the equivalent discipline.
The gotcha
Server-rendering a theme-dependent value causes a hydration mismatch, because the server has no idea what the client's theme is. React logs a hydration error and may discard the server HTML entirely. Render theme-dependent UI only after mount, or drive it purely through CSS so the markup is identical either way.
Common pitfalls
Inverting colors instead of designing the dark palette
Mechanically flipping light and dark produces harsh, over-saturated results. Dark themes need desaturated accents, softer contrast (near-black rather than pure black, near-white rather than pure white), and elevation expressed by lighter surfaces rather than shadows, which are invisible on dark.
Forgetting images, illustrations, and embeds
A logo with a transparent background and dark strokes disappears. Charts hardcode light-mode colors. Third-party embeds keep their own theme. Use `<picture>` with `prefers-color-scheme` media queries for images, and pass theme tokens into chart libraries.
Missing color-scheme
Without `color-scheme` on the root, scrollbars, form controls, and date pickers stay light against your dark UI. It's a one-line fix that most implementations skip.
Not handling live system changes
A user on 'system' whose OS switches at sunset expects the page to follow without a reload. Pure CSS media queries do this for free; JavaScript-driven implementations need a `change` listener on the media query.
Questions people actually ask
How do I prevent the flash of the wrong theme?
A synchronous inline script in `<head>`, before stylesheets, that reads the stored preference and sets an attribute on `<html>`. It must block — that's the mechanism. Anything deferred runs after first paint, which is precisely when the flash happens.
Should dark mode default to the system setting?
Yes. Default to `system` and treat an explicit toggle as an override that persists. That respects the OS-level choice users have already made while still letting them differ on your site specifically.
Where should the preference be stored?
`localStorage` for anonymous users, since it must be readable synchronously before paint. For signed-in users, mirror it to their profile so it follows across devices, but keep reading `localStorage` first — a server round trip is too slow to beat first paint.