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>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> );}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>1234567
---import ThemeToggle from "./ThemeToggle";---
<header> <ThemeToggle client:load /></header>