We’ve all been there: you’re peacefully browsing a site in dark mode at 2 AM, you click a link, and then… BAM! A totally white screen blasts your retinas for half a second before the dark mode reactivates.

This phenomenon has a name: the FOUC (Flash of Unstyled Content).

It happens when your JavaScript, which is responsible for applying the dark theme, executes after the browser has started painting the page on the screen.

Today, we’re going to see how to implement a robust Dark Mode in Astro, based on CSS variables, and most importantly: how to block that cursed flash of light.

1. The Foundation: CSS Variables

Before manipulating JavaScript, we need to prepare our CSS. The cleanest method is to use CSS variables attached to the root of our document (:root) and modify them when a .dark class is added to the <html> tag.

/* src/styles/global.css */

/* Light Theme (Default) */
:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --primary-color: #3b82f6;
}

/* Dark Theme (Active when <html> has the 'dark' class) */
:root.dark {
  --bg-color: #121212;
  --text-color: #e5e5e5;
  --primary-color: #60a5fa;
}

/* Global application */
body {
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s ease, color 0.3s ease;
}

2. The Anti-Flash Secret: The Blocking Script

This is where the war against the white flash is fought. In a classic framework (like React), you often fight with Server-Side Rendering (SSR) to inject the theme.

In Astro, it’s much simpler. We just need to add a small inline script directly inside our <BaseHead /> component (the one that generates the <head> tag).

The is:inline attribute is crucial: it tells Astro not to defer this script. The browser will read and execute it even before it starts drawing the <body>.

---
// src/components/BaseHead.astro
---
<head>
  <script is:inline>
    const theme = (() => {
      // 1. Check if a theme is saved in localStorage
      if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
        return localStorage.getItem('theme');
      }
      // 2. Otherwise, check the operating system preferences
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        return 'dark';
      }
      // 3. By default, return 'light'
      return 'light';
    })();

    // Apply the class immediately, before rendering the <body>!
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
    
    // Save the preference to ensure its availability
    window.localStorage.setItem('theme', theme);
  </script>
</head>

And just like that, the flash is dead. The browser knows whether it should paint a black or white background before it even starts.

3. The Toggle Button (The ClientRouter Trap)

Now that our foundation is solid, we need a button to let the user pick their side.

Let’s create a ThemeToggle.astro component. If you followed my article yesterday on the ClientRouter (View Transitions), you already know that a simple addEventListener('click') won’t survive navigation. We must use Astro’s native events!

---
// src/components/ThemeToggle.astro
---
<button id="theme-toggle" aria-label="Toggle theme">
  🌓 Toggle theme
</button>

<script>
  function setupThemeToggle() {
    const button = document.getElementById('theme-toggle');
    if (!button) return;

    button.addEventListener('click', () => {
      // Toggle the class on the <html> tag
      const isDark = document.documentElement.classList.toggle('dark');
      
      // Save the user's choice
      localStorage.setItem('theme', isDark ? 'dark' : 'light');
    });
  }

  // Runs on initial load AND after each navigation (View Transitions)
  document.addEventListener('astro:page-load', setupThemeToggle);
</script>

4. Managing View Transitions: The Ultimate Event

If you use the ClientRouter (which I highly recommend), there is one last detail. During a page change, Astro replaces the entire document.documentElement. This means that the .dark class we painstakingly calculated can “drop” during the transition between two pages, recreating a flash!

To avoid this, we must tell Astro to reapply the theme right after replacing the page content, but before displaying it. That is the role of the astro:after-swap event.

Simply add this to the <script> in your <BaseHead /> (following the inline script we saw in step 2):

// Add to your BaseHead.astro, below the is:inline script
document.addEventListener('astro:after-swap', () => {
  if (localStorage.getItem('theme') === 'dark') {
    document.documentElement.classList.add('dark');
  } else {
    document.documentElement.classList.remove('dark');
  }
});

Conclusion

By combining a small synchronous script in the <head>, well-thought-out CSS variables, and respecting the lifecycle of Astro’s View Transitions, you get a Dark Mode that is resilient, accessible (respecting OS preferences), and above all… one that won’t blind your users after nightfall!