Apply the saved theme with a small inline script in the document head, before the stylesheet paints, so the attribute is set before first paint. In seven timed loads that placement never flashed, while a deferred external script and an end-of-body script flashed every time. If you only need to follow the system setting, CSS alone is enough.
Key takeaways
- The flash is a timing bug, not a styling bug. The CSS is right; the attribute that switches it arrives after the browser has already painted.
- An inline script in the head ran before first paint. 0 flashes in 7 loads in Chromium.
- An external deferred script flashed in 7 of 7 loads when it took 400 ms to arrive, because deferred scripts wait for the document to be parsed.
- A script at the end of a slowly streamed body also flashed in 7 of 7. The first half of the page painted light before the script was reached.
- If you only follow the OS setting, use CSS.
prefers-color-schemeneeds no script and showed the dark background immediately. - A strict Content Security Policy needs a hash or nonce for the inline script. The CSP guide on this site covers it.
Why it flashes
The browser paints as soon as it has enough to paint. If your dark theme is activated by JavaScript, say by
setting data-theme="dark" on the root element, anything that delays that script lets the browser
paint the default, light theme first. The visitor sees white, then dark.
The test
Three pages with identical CSS and a saved theme of dark, loaded seven times each in headless Chromium on a local server. I compared when the theme script ran with when first paint happened.
| Where the script sits | Flash | Median gap |
|---|---|---|
Inline in <head> | 0 of 7 | −14 ms (before paint) |
External defer script, arriving 400 ms late | 7 of 7 | +380 ms |
| Inline at the end of a body streamed in two chunks 400 ms apart | 7 of 7 | +375 ms |
The 400 ms delay is one I chose to make the cause visible. On a fast connection the gap would be smaller, but it would still exist for a deferred script, which by definition runs after parsing.
The fix
Put this in the head, before the stylesheet link, and keep it tiny:
<script>
try {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
</script>
Blocking scripts in the head are normally something to avoid, but this one does a storage read and one attribute write. The try/catch matters because storage can throw in private windows. If your site sets a Content Security Policy, an inline script needs a hash or nonce; the approach is covered in CSP on a static site with inline scripts.
The no-script option
If you only want to follow the operating system setting, skip the script. Define the dark values inside
@media (prefers-color-scheme: dark) and add <meta name="color-scheme" content="light dark">
so the browser also styles the canvas and form controls. In the test, a dark-OS profile showed the dark
background with no script. The trade-off is that visitors cannot override the system setting. For a manual
toggle, you need the inline script, and for choosing the colours themselves, see
designing light and dark themes without muddy colours.
@media (prefers-color-scheme: dark) {
:root { --bg: #111; --fg: #eee; }
}
<meta name="color-scheme" content="light dark">