Stop Blaming prefers-color-scheme For Your Theme Flash
Your dark mode toggle flashes light for a frame on every reload — but prefers-color-scheme alone never does that. The real bug is where your theme script runs relative to the browser's first paint.

You ship a dark mode toggle. It works — click it, the page goes dark, click again, it goes back. You're happy. Then you refresh the page with dark mode saved, and for one frame the screen is a blinding white rectangle before it snaps to black.
It's not every browser. It's not every time. But it's there often enough that someone on your team files it as "dark mode flickers" and you go looking for the bug in prefers-color-scheme.
It's not there. The media query is working exactly as designed. The bug is somewhere you didn't think to look: the order your scripts run in, relative to the one paint you don't get a second chance at.
Here's the toggle, built the way most tutorials show it:
Perfectly reasonable code. It checks localStorage for a saved choice, falls back to the OS preference, and sets a data-theme attribute your CSS keys off. Toggling works instantly because it's all synchronous DOM manipulation in response to a click.
So why does reloading with dark mode saved still flash light?
Because this script, like almost every script you write, loads after the browser has already parsed your HTML and applied your CSS. By the time it runs, the page has already painted once — in whatever theme your CSS defaults to, usually light, because that's what browsers assume when nothing says otherwise. Your script then flips the theme a beat later, and that flip is the flash. You didn't write a buggy script. You wrote a script that runs too late to stop a paint that's already happened.
Before you scroll — where do you think this script is sitting in the HTML? Top of <head>? Bottom of <body>? It matters more than anything else in this post.
Here's the part that trips people up: if you throw out the toggle entirely and do dark mode with pure CSS, there's no flash, ever.
No JavaScript, no flash. prefers-color-scheme is a media feature the browser evaluates while it's constructing the render tree — the same moment it resolves @media (min-width: 768px) or prefers-reduced-motion. That happens before the first paint, not after it, so there's nothing to flip once pixels are on screen. CSS parsing is render-blocking specifically so the browser doesn't have to paint twice.
Current browsers only ever resolve it to light or dark — an earlier draft of the spec defined a third value, no-preference, for "the OS has no setting," but no engine shipped it as a live, matchable state, so it was dropped; when there's nothing to detect, browsers just report light. Either way, it's read-only. It tells you what the OS wants. It has no opinion about a manual toggle, localStorage, or anything your app decides on top of it. The moment you add "but let the user override it," you've introduced a second source of truth that only JavaScript can read — and JavaScript, by default, runs after paint.
So the honest version of the bug report isn't "prefers-color-scheme flickers." It's "my override script runs after the thing it's supposed to override has already rendered."
The browser doesn't execute scripts in document order and then paint — it paints as it goes, and it blocks on anything synchronous it hits along the way. That's the lever. If you put a small, blocking script in <head>, before your stylesheet and before the rest of the page, it runs during that initial block — before there's anything to paint yet.
Three things make this work, and dropping any one of them breaks it:
- It's inline, not
<script src="...">. An external file is one more network request the browser has to fetch and parse before it can run — more time for the default paint to sneak in. - It has no
asyncordefer. Both exist specifically to let the browser not block on a script. You want the opposite here — on purpose, once, for eight lines. - It's in
<head>, above the stylesheet. Your CSS still needs[data-theme="dark"] { ... }rules to react to the attribute — the script just has to set that attribute before the CSS gets a chance to compute without it.
Move the same code to the bottom of <body>, or into a deferred file, or behind DOMContentLoaded, and you've recreated the exact bug you just fixed — you've just moved the race further down the page instead of removing it.
If you're doing this in a React app — and especially in a server-rendered one like Next.js — you'll hit a second-order problem the moment this script works.
The server renders <html> with no data-theme at all; it has no access to the visitor's localStorage. Your inline script then sets that attribute directly on the real DOM node, outside of React, before React ever hydrates. React's hydration pass compares the server-rendered markup it expected against what's actually in the DOM — and now they don't match on that one attribute. You'll get a hydration warning for an attribute you set on purpose.
The fix isn't to stop setting it. It's to tell React this one attribute is expected to differ:
suppressHydrationWarning only silences the warning for that element's own attributes and text — it doesn't disable hydration checks anywhere else in the tree, so it's safe to scope to just the <html> tag carrying the theme. This is the exact tradeoff theme libraries like next-themes are built around: inject the blocking script, suppress the one expected mismatch, let everything else hydrate normally.
The flash was never a CSS problem, and it was never really a prefers-color-scheme problem either — that media feature did its job correctly the entire time. The bug was a question of sequence: something ran after the browser's first paint that needed to run before it. Once the override script moves into that pre-paint window, there's no gap left for the wrong theme to be visible in.
Go check your own toggle. Is the theme script inline in <head>, or did it end up wherever your bundler happened to put it? If it's the second one, you already know what to fix tonight — what's the worst flash you've shipped without noticing?
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.