Web Development

Building a Dark Mode Toggle That Doesn't Flash on Load

A working dark mode toggle with localStorage persistence, built from a version that flashed light-mode white on every single page load, and the exact inline script that fixed it.

By Aissam Ait Ahmed Web Development 0 comments

The first version of this site's dark mode toggle worked correctly in the sense that clicking it switched themes and the choice persisted across page loads — and every single page load still flashed a blinding half-second of white light-mode background before JavaScript ran and applied the saved dark preference. This is the actual fix, and the specific reason a script tag's position in the document is what determines whether this bug exists at all.

The version that flashes, and why

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <!-- page content -->

  <script>
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme === 'dark') {
      document.body.classList.add('dark');
    }
  </script>
</body>
</html>

This script runs correctly and does apply the saved theme — but it runs at the very end of the document, after the browser has already parsed the head, requested and applied the default (light) stylesheet, and started painting the page in light mode. By the time this script executes and adds the dark class, the browser has already rendered at least one visible frame in light mode. That frame is the flash — it's not a bug in the theme logic itself, it's a timing problem: the theme decision happens after the browser has already committed to a visual state.

The fix: an inline script in the head, before any CSS or content renders

<!DOCTYPE html>
<html>
<head>
  <script>
    (function () {
      const savedTheme = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <!-- page content -->
</body>
</html>

Two changes matter here, and both are about timing, not the theme logic itself. First, the script moved into <head>, before the stylesheet link — browsers execute a synchronous inline script (no src attribute, no defer/async) the moment they parse it, blocking further parsing until it finishes, which means the dark class gets added to <html> before the browser ever paints a single frame, light or dark. Second, the class now targets document.documentElement (the <html> tag) rather than document.body, because at the point this script runs, <body> hasn't been parsed yet at all — it doesn't exist as a DOM node the script could target even if it wanted to.

Why this specific script has to be synchronous and inline

It's tempting to move this into an external file for cleanliness, but an external script — even one placed early in <head> — introduces a network request the browser has to wait on before executing it, and that wait is exactly the same category of delay that caused the original flash, just shifted earlier. A tiny inline script has zero network latency; it executes the instant the parser reaches it. For this one specific, timing-critical piece of logic, inlining it directly in the HTML document is the correct trade-off against the usual "keep JS in external files" convention — the whole point is executing before anything else has a chance to render.

Respecting system preference as a real default, not an afterthought

The fixed version also checks prefers-color-scheme: dark via matchMedia when there's no saved preference yet, rather than defaulting to light mode unconditionally for every new visitor. A first-time visitor whose OS is already set to dark mode gets a dark page on their very first load, without ever having toggled anything — treating the system-level preference as the real default, and localStorage only as an explicit override once someone has actually made a choice, rather than treating light mode as a universal default that dark-mode-preferring visitors have to manually correct every time.

The toggle button itself, and keeping localStorage in sync

function toggleTheme() {
  const isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

classList.toggle() conveniently returns the new state (true if the class is now present), which avoids a separate read-after-write check — the toggle and the state read happen as one atomic operation rather than two steps that could theoretically disagree if something else touched the class in between.

Syncing across multiple open tabs

A subtler issue surfaced once the toggle was in daily use: toggling the theme in one browser tab didn't update an already-open second tab of the same site, which read as a bug to at least one confused early user who had the site open in two tabs side by side. The storage event fires on other tabs (not the one that made the change) whenever localStorage is written to, which is exactly the hook needed to keep multiple open tabs in sync without any polling:

window.addEventListener('storage', (event) => {
  if (event.key === 'theme') {
    document.documentElement.classList.toggle('dark', event.newValue === 'dark');
  }
});

This listener only ever fires in tabs other than the one where the change originated — the tab that actually called localStorage.setItem() never receives its own storage event, which is a deliberate part of the API's design and worth knowing, since testing this fix in a single tab alone will never demonstrate that it works; it genuinely requires two tabs open side by side to observe.

The bug this fix introduced, and the second fix it needed

Shipping the head-script fix solved the flash but introduced a smaller, separate issue: a visitor with JavaScript disabled entirely now saw no dark mode support at all, not even respecting their OS-level preference, because the entire mechanism depends on script execution. Since this was an acceptable trade-off for the actual visitor base (JS-disabled traffic was a negligible fraction, confirmed by checking real analytics rather than assuming), no further fix shipped — but it's worth stating explicitly as a known, deliberate trade-off rather than an unnoticed regression, since a site with a meaningfully larger no-JS audience would need a CSS-only fallback using prefers-color-scheme directly in a stylesheet, accepting that such a fallback can't respect a saved manual override the way the JavaScript version can.

Applying the same pattern beyond just light/dark

The exact head-script pattern generalizes to any early-committed visual preference beyond a simple two-value theme — a font-size preference, a high-contrast accessibility mode, a saved layout density setting. The core requirement is always the same regardless of what the preference actually controls: read the saved value and apply its corresponding class or attribute on document.documentElement, synchronously, inline, before the stylesheet loads, so the very first painted frame already reflects the visitor's actual saved choice rather than a default that then has to be corrected a moment later. Once this pattern is understood for one preference, extending it to a second one is closer to copying a few lines than solving a new problem from scratch.

A checklist for auditing an existing dark mode implementation

  • Is the theme-detection script inline and placed before the stylesheet link in <head>, not deferred, async, or sitting in <body>?
  • Does it target document.documentElement, not document.body, since <body> doesn't exist yet at the point this script needs to run?
  • Does it fall back to prefers-color-scheme when no saved preference exists, rather than defaulting every new visitor to light mode?
  • Has the flash actually been re-tested under throttled network conditions, not just on a fast local connection where a brief flash is easy to miss entirely?

None of these are exotic requirements once stated explicitly, but each one individually is easy to get subtly wrong in a way that still "mostly works," which is exactly why the original flash shipped in the first place and went unnoticed for as long as it did — it's a real UX regression that's genuinely easy to not consciously perceive as a bug rather than just "how this page loads." A similar category of quietly-shipped, easy-to-miss issue is the subject of five accessibility fixes teams miss most often — both are cases where something technically works while still being a real, fixable rough edge most teams don't notice until someone points at it directly.

Testing the fix without guessing

Confirming the flash is actually gone isn't a "look at it and see if it feels right" check — Chrome DevTools' network panel has a CPU/network throttling option specifically useful here, since a fast local connection can mask a flash that would be clearly visible on a slower connection where the stylesheet and script take measurably longer to load relative to how fast the page starts painting. Testing under throttled conditions caught the fact that a slow connection could still show a brief flash if the inline script itself sat after a large blocking resource in the head rather than before it — reordering the script to be the very first thing in <head>, ahead of even the stylesheet link, closed that remaining gap. A quick manual check worth doing alongside the throttled test: hard-refresh (not a cached reload) several times in a row watching closely for even a single frame of the wrong theme, since an intermittent flash caused by a race between script execution and the browser's first paint can be inconsistent enough that one clean test run isn't sufficient proof the fix is actually complete, particularly on a fast local development machine where the entire window between parsing and first paint is small enough that a rare, occasional flash could easily be missed on any single manual check alone.

If your dark and light modes serve visually different image assets (a differently colored logo variant, for instance) rather than just different CSS colors, compressing each variant separately through an image compressor matters for both modes independently — it's an easy detail to optimize for one theme and forget for the other, especially since whichever theme a given developer's own machine defaults to is the one that tends to get tested and tuned first, by default, without anyone deciding that on purpose. Auditing both themes deliberately, rather than assuming parity because one of them looks fine, is worth doing once as a specific pass rather than trusting it happened naturally along the way.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools