Astro

Implement dark mode in your Astro project.

Implementation in Astro

Astro generates static HTML by default. To avoid the “Flash of Unstyled Content” (FOUC), you should inject a small inline script in your <head> to evaluate the theme before the page renders.

Configure the inline script

Add the following script to the <head> of your main layout file (Layout.astro). This script reads the theme from localStorage or defaults to the system preference.

src/layouts/Layout.astro
12345678910111213141516171819202122232425262728293031323334353637383940
---
// Your imports...
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>Astro App</title>
<script is:inline>
const getThemePreference = () => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const isDark = getThemePreference() === "dark";
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
if (typeof localStorage !== "undefined") {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
}
</script>
</head>
<body>
<slot />
</body>
</html>

Add a Theme Toggle (React)

You can use the standalone ThemeToggle component which handles document classes and local storage directly without requiring a context provider:

src/components/ThemeToggle.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
import { useState, useEffect } from "react";
import { Sun, Monitor, Moon } from "lucide-react";
import { cn } from "@/lib/utils";
type Theme = "light" | "system" | "dark";
const LENS_POSITIONS: Record<Theme, string> = {
light: "translate-x-0",
system: "translate-x-full",
dark: "translate-x-[200%]",
};
const THEME_OPTIONS = [
{ value: "light", icon: Sun, label: "Light theme" },
{ value: "system", icon: Monitor, label: "System theme" },
{ value: "dark", icon: Moon, label: "Dark theme" },
] as const;
export default function ThemeToggle({ className }: { className?: string }) {
const [mounted, setMounted] = useState(false);
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = (localStorage.getItem("theme") as Theme) || "system";
setTheme(stored);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setMounted(true);
});
});
}, []);
useEffect(() => {
if (!mounted) return;
const root = window.document.documentElement;
localStorage.setItem("theme", theme);
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme, mounted]);
return (
<div
className={cn(
"relative border border-glass-border shadow-glass-sm w-[97px] h-8 flex items-center rounded-glass-sm",
className
)}
role="radiogroup"
aria-label="Theme toggle"
>
<div
className={cn(
"absolute w-8 h-8 rounded-glass-sm z-0 glass",
mounted ? "transition-transform duration-300 cubic-bezier(0.4, 0.0, 0.2, 1)" : "",
LENS_POSITIONS[theme]
)}
/>
{THEME_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={cn(
"flex z-10 h-8 w-8 rounded-glass-sm transition-colors duration-200 justify-center items-center",
theme === value ? "text-foreground" : "text-muted-foreground hover:text-foreground"
)}
aria-label={label}
role="radio"
aria-checked={theme === value}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
);
}

Render the component

When using the component in an .astro file, remember to include the client:load directive to ensure interactivity:

src/components/Header.astro
1234567
---
import ThemeToggle from "./ThemeToggle";
---
<header>
<ThemeToggle client:load />
</header>