The Notebook · Vol. I, Issue № 03
May 2026 · Gilbert, Arizona

Notes from a software consultancy.

WEB · · ~4 min read

The Flash of Wrong Theme

How to avoid the jarring flash of a light theme before dark mode kicks in by applying themes before the first paint.

If you’ve ever built a site with a dark mode, you’ve probably seen this: the page loads, briefly shows the light theme, and then snaps to dark, or vice-versa. It’s jarring in the best case and – if you happen to be reading in a dark room – actively unpleasant in the worst. It has a name: Chris Coyier called it FART1, for Flash of inAccurate coloR Theme, which is the kind of tongue-in-cheek acronym you only get to coin once. It’s one of those problems that’s trivial to cause and slightly tricky to avoid. Others have written about under-engineered ways to avoid it2, and the various pitfalls of getting it wrong3.

So, then, the cause is fairly simple. Your CSS applies a theme based on something (a class on <html>, a data-theme attribute, a CSS custom property), and that something is usually set by JavaScript that reads from localStorage or matchMedia. By the time the JavaScript runs, the browser has already painted the page with whatever the default was. You get a flash of the default, then the corrected theme kicks in a moment later.

The fix

The trick is to apply the theme before the first paint. And the only way to do that reliably is with a small inline script in the <head>, placed before any stylesheet or external script that would defer execution:

<script>
  (function() {
    var saved = localStorage.getItem('site-theme') || 'auto';
    var theme;
    if (saved === 'auto') {
      theme = window.matchMedia('(prefers-contrast: more)').matches ? 'high-contrast'
        : window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
    } else {
      theme = saved;
    }
    if (theme !== 'dark') {
      document.documentElement.setAttribute('data-theme', theme);
    }
  })();
</script>

A few things worth pointing out:

It has to be inline. An external <script src="..."> won’t work, even with async, because the browser has to fetch it. By the time it arrives, the page might already be painted. Inline scripts execute synchronously as the HTML parser encounters them, so the attribute is set before any rendering happens.

It has to be in the <head>. Specifically, before anything that blocks parsing, and certainly before any <body> content. The moment the browser starts painting, you’ve lost the battle.

It’s old-school JavaScript on purpose. This script is simple enough that you don’t need an additional build step and all the complexity that that implies. It’s 14 lines including the <script> tags and under 500 bytes uncompressed.

The default theme gets no attribute. Notice the if (theme !== 'dark') check. Dark is the default, so if that’s the resolved theme, we don’t set anything – the CSS handles it as the baseline. Any other theme gets data-theme="light" or data-theme="high-contrast" and the CSS custom properties cascade from there. This keeps the HTML clean in the common case and makes the “no JavaScript” fallback (your default theme) the sensible one.

The auto mode is doing work

The interesting bit in that script is what “auto” actually means. On headius.com, “auto” is the default when a visitor hasn’t made an explicit choice, and it checks two media queries in priority order:

  1. prefers-contrast: more – if you’re at this level, your OS is telling the browser you need high-contrast visuals, and that takes precedence over colour preference.
  2. prefers-color-scheme: light – if that didn’t match, fall back to the light/dark split.

That priority order matters. High-contrast is a more specific accessibility need than a colour preference, so if the user’s OS says both “I prefer dark mode” and “I prefer more contrast”, they get the high-contrast theme, not the dark one. Honouring the more specific need is the right call.

What about light as the default?

If your site is light-by-default, flip the script around: check if the resolved theme isn’t light before setting the attribute. Same structure, different baseline. The important thing is that your “no JavaScript at all” users (there are some!) get a sensible, usable theme without needing the script to run.

The query-string escape hatch

I snuck one extra thing into the real version of this script on headius.com: a ?theme= query string parameter that, if valid, gets written into localStorage before the resolution logic runs. That was added for a specific reason in that I needed a way to audit each theme with a remote Lighthouse service that couldn’t click the theme switcher. But it turned out to be a nice feature on its own: I could share a link with the client to let them see a specific theme.

Why bother?

The flash is one of those things that, if you never fix it, nobody explicitly complains about but it absolutely contributes to a site feeling cheap or broken. My final version is a 30-line fix for a problem that makes your site feel considered, which is worth the effort even if most visitors can’t articulate what they’re reacting to.

And if you’re auto-detecting things like prefers-contrast: more, the flash fix is the only way to do it without your accessibility-oriented visitors seeing a brief, unreadable version of your page before the correct one loads.