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-scheme needs 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.

Theme applied minus first paint; negative means before paint.
Where the script sitsFlashMedian gap
Inline in <head>0 of 7−14 ms (before paint)
External defer script, arriving 400 ms late7 of 7+380 ms
Inline at the end of a body streamed in two chunks 400 ms apart7 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">

What this does not cover

This is one browser on a local server. Chromium headless, one machine, an artificial 400 ms delay and a minimal page. I did not test Safari or Firefox, which may differ in when first paint happens relative to inline scripts. A page with a large inline stylesheet or a slow network may show a longer or shorter gap. 'Flash' here means the script ran after first-paint time, not that I looked at screenshots.

Badri Dutta

Software engineer · 8 years technical SEO

Written after building a dark-mode toggle for this site and measuring the three obvious script placements instead of assuming which one flashes.

Full background →