Dark mode is a feature most users expect at this point. In this guide, we'll add a dark mode toggle to an Astro site using Tailwind CSS, with support for system preferences and saved user settings.
Step 1: Configure Tailwind for Dark Mode
Before we do anything else, we need to make sure Tailwind is actually set up to support dark mode properly. If you’re using Tailwind v4, and you're not using a tailwind.config.js file, you’ll want to add a custom variant to your Tailwind CSS so the dark: utilities work the way we expect:
@custom-variant dark (&:where(.dark, .dark *)); If you are still using an older version of Tailwind prior to V4, you can add the following to your tailwind.config.js file:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'selector',
// ...
} This ensures that dark mode styles apply when the .dark class is present on the <html> element.
Step 2: Ensure Dark Mode is Applied on Load
If a user has dark mode enabled, the last thing you want is a bright flash of light mode before the page finishes loading. To avoid that, add this script inside your <head>:
<script is:inline>
// Ensures dark mode is applied before styles load
if (
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script> This runs immediately and applies the correct theme before the page renders. The is:inline attribute tells Astro to inline the script directly into the HTML so it can execute as early as possible.
If you're using Astro's page transitions (now ClientRouter), you'll also want to reapply dark mode after navigation swaps:
<script is:inline>
document.addEventListener('astro:after-swap', () => {
if (
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
}
});
</script> If you're not using page transitions, you can skip this part.
Step 3: Create the Dark Mode Toggle Component
Next, we'll create a simple React component to handle the dark mode toggle:
import { useEffect, useState } from 'react';
const DarkModeToggle = () => {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
setDarkMode(document.documentElement.classList.contains('dark'));
}, []);
const toggleDarkMode = () => {
document.body.style.transition = 'color 0.1s, background-color 0.3s';
if (darkMode) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
setDarkMode(!darkMode);
};
return (
<button onClick={toggleDarkMode} aria-label="Toggle dark mode" className="p-2">
{darkMode ? '🌙' : '☀️'}
</button>
);
};
export default DarkModeToggle; This component is minimal, effective, and fully enables dark mode.
Step 4: Add the Toggle to Your Navbar
You could just dump this anywhere, but let's be civil and place it neatly in the navbar:
---
import DarkModeToggle from '../components/DarkModeToggle';
---
<nav class="p-4 flex justify-between">
<h1>My Website</h1>
<DarkModeToggle client:only="react" />
</nav> Because DarkModeToggle is a React component, we use client:only="react" to tell Astro to hydrate it on the client only, keeping the rest of the navbar static - you can read more about that here. Now users can switch between dark and light mode seamlessly.
Step 5: Add the Tailwind class where necessary
Finally, add the appropriate Tailwind utility classes to your components. For example, on the <body> element you might use bg-white dark:bg-black. When the .dark class is present on the <html> element, the dark:bg-black class will take effect. For a better explanation, see the Tailwind docs.
Conclusion
🎉 Your Astro site now supports both dark and light modes. Tailwind's .dark class keeps styling simple, and the React toggle makes switching effortless.
🌙 Finally, a site that won’t blind your users at night.