Theme toggle
Light and dark mode switch. Put ThemeScript in <head>.
Code
Copy into src/components/ph/ .
theme.ts
export type Theme = "light" | "dark";
const KEY = "ph-theme";
export function getTheme(): Theme {
try {
const saved = localStorage.getItem(KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {}
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
}
export function setTheme(theme: Theme | null) {
try {
if (theme) localStorage.setItem(KEY, theme);
else localStorage.removeItem(KEY);
} catch {}
applyTheme(getTheme());
}
// Inline in <head> so the page never flashes the wrong theme.
export const themeScript = `try{var t=localStorage.getItem("${KEY}");if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.toggle("dark",t==="dark")}catch(e){}`;
ThemeToggle.astro
---
import Icon from "./Icon.astro";
type Props = {
class?: string;
};
const { class: className } = Astro.props;
---
<button
type="button"
data-ph-theme-toggle
aria-label="Toggle dark mode"
title="Middle-click to follow your system"
class:list={["inline-flex cursor-pointer items-center justify-center p-1.5 transition-transform duration-100 active:scale-[0.97]", className]}
>
<Icon name="sun" class="size-4.5 dark:hidden" />
<Icon name="moon" class="hidden size-4 -rotate-6 dark:block" />
</button>
<script>
import { applyTheme, getTheme, setTheme } from "./theme";
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-ph-theme-toggle]")) {
b.addEventListener("click", () => setTheme(getTheme() === "dark" ? "light" : "dark"));
b.addEventListener("auxclick", (e) => {
if (e.button === 1) setTheme(null);
});
}
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme(getTheme()));
</script>
ThemeScript.astro
---
import { themeScript } from "./theme";
---
<script is:inline set:html={themeScript} />
Copy into components/ph/ (or src/components/ph/).
theme.ts
export type Theme = "light" | "dark";
const KEY = "ph-theme";
export function getTheme(): Theme {
try {
const saved = localStorage.getItem(KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {}
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
}
export function setTheme(theme: Theme | null) {
try {
if (theme) localStorage.setItem(KEY, theme);
else localStorage.removeItem(KEY);
} catch {}
applyTheme(getTheme());
}
// Inline in <head> so the page never flashes the wrong theme.
export const themeScript = `try{var t=localStorage.getItem("${KEY}");if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.toggle("dark",t==="dark")}catch(e){}`;
theme-toggle.tsx
"use client";
import { useEffect } from "react";
import { cn } from "./cn";
import { Icon } from "./icon";
import { applyTheme, getTheme, setTheme, themeScript } from "./theme";
export { themeScript };
export function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
}
export function ThemeToggle({ className }: { className?: string }) {
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme(getTheme());
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
return (
<button
type="button"
aria-label="Toggle dark mode"
title="Middle-click to follow your system"
onClick={() => setTheme(getTheme() === "dark" ? "light" : "dark")}
onAuxClick={(e) => e.button === 1 && setTheme(null)}
className={cn("inline-flex cursor-pointer items-center justify-center p-1.5 transition-transform duration-100 active:scale-[0.97]", className)}
>
<Icon name="sun" className="size-4.5 dark:hidden" />
<Icon name="moon" className="hidden size-4 -rotate-6 dark:block" />
</button>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");