loke.dev
Header image for A Small Header for a Flicker-Free Dark Mode

A Small Header for a Flicker-Free Dark Mode

Stop relying on blocking JavaScript in your head tag to prevent the dreaded dark-mode flash in server-rendered applications.

· 4 min read

I sat down to check my site at 3 AM the other day and felt like I’d just stared directly into a solar flare. It was that split-second flash—the "Flash of Unstyled Light Mode"—that happens right before your fancy React component hydrates.

If you’re building a server-rendered application (Next.js, Remix, Astro, etc.), you've likely fought this. You have a toggle, you save the preference to localStorage, and you feel great—until you refresh and your retinas are briefly incinerated.

The typical advice is to throw a blocking script in your <head>. While that works, we can do it with more grace and fewer layout shifts.

Why the flash happens

Browsers are incredibly efficient at rendering HTML as it streams in. If your theme logic lives in a client-side bundle (like a useEffect in React), the browser has already painted the default background (usually white) by the time your JavaScript wakes up, checks localStorage, and adds a .dark class to the body.

To stop this, we need to execute a tiny bit of logic before the browser paints the first pixel.

The "Blocking" Script (That isn't a sin)

We’re usually told that blocking JavaScript is the enemy of performance. In this specific case, a few lines of synchronous code in your <head> is actually a performance *feature*. It prevents a massive layout shift and a jarring visual change.

Here is the "small header" script you should drop into your HTML:

<head>
  <script>
    (function() {
      try {
        var theme = localStorage.getItem('theme');
        var supportDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches === true;
        
        if (theme === 'dark' || (!theme && supportDarkMode)) {
          document.documentElement.classList.add('dark');
        } else {
          document.documentElement.classList.remove('dark');
        }
      } catch (e) {}
    })();
  </script>
</head>

Why this works:
1. It uses an IIFE (Immediately Invoked Function Expression) to avoid polluting the global scope.
2. It checks for a saved preference first.
3. It falls back to the system preference (prefers-color-scheme) if the user hasn't toggled it yet.
4. It targets document.documentElement (the <html> tag). This is important because the <body> might not even exist when this script runs.

Leveraging the color-scheme property

Even with the script above, you might see a tiny "white border" or scrollbar flicker. Modern browsers have a CSS property specifically designed to tell the browser's internal UI (like form controls and scrollbars) which theme we're aiming for.

You should update your script to also set the color-scheme style:

if (theme === 'dark' || (!theme && supportDarkMode)) {
  document.documentElement.classList.add('dark');
  document.documentElement.style.colorScheme = 'dark';
} else {
  document.documentElement.classList.remove('dark');
  document.documentElement.style.colorScheme = 'light';
}

This tells the browser engine, "Hey, even before you finish parsing the CSS, know that this page is intended to be dark."

The "Real" Header: Client Hints

If you want to get truly fancy and avoid the script entirely, we can look at HTTP Client Hints. This is the most "performance-pure" way to do it, though it requires a server that can read headers.

Browsers can send a Sec-CH-Prefers-Color-Scheme header to your server. If your server sees this, it can serve the correct HTML/CSS immediately.

First, you have to tell the browser you want that info:

Accept-CH: Sec-CH-Prefers-Color-Scheme

Then, on subsequent requests, your server gets a header like this:

Sec-CH-Prefers-Color-Scheme: dark

If you're using a framework like Remix or Next.js (with Middleware), you can read this header and inject the correct class directly into the HTML string before it ever leaves the server. No script, no flash, just pure, dark bliss.

The catch? Client Hints currently require a secure connection (HTTPS) and aren't supported in every single browser yet (looking at you, Safari). So, keep that small script as a fallback.

Implementation Gotcha: Hydration Mismatches

If you’re using a framework like React, you might run into "Hydration Mismatches." This happens when the server thinks the theme is light, but your script changed it to dark on the client before React took over.

To fix this, make sure your theme toggle component only renders its "active" state *after* it has mounted.

const ThemeToggle = () => {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) {
    // Return a placeholder or a generic icon to avoid 
    // mismatching the server-rendered HTML
    return <div className="p-2 w-8 h-8" />;
  }

  return (
    <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>
      {/* Your actual toggle UI */}
    </button>
  );
};

Wrapping up

Dark mode isn't just a checkbox; it's a first impression. By moving your theme detection out of your main bundle and into a tiny, strategic script in the <head>—or better yet, leveraging HTTP headers—you eliminate that amateur-hour flicker.

Your users' eyes (and their 3 AM browsing sessions) will thank you.