this repo has no description
1"use client";
2
3import * as React from "react";
4
5type Theme = "system" | "light" | "dark";
6
7function getSystemTheme(): "light" | "dark" {
8 if (typeof window === "undefined") return "light";
9 return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
10}
11
12function getResolvedTheme(theme: Theme): "light" | "dark" {
13 if (theme === "system") return getSystemTheme();
14 return theme;
15}
16
17export function ThemeToggle() {
18 const [theme, setTheme] = React.useState<Theme>("system");
19 const [isClicked, setIsClicked] = React.useState(false);
20 const [mounted, setMounted] = React.useState(false);
21
22 React.useEffect(() => {
23 setMounted(true);
24 const saved = localStorage.getItem("theme") as Theme | null;
25 if (saved) setTheme(saved);
26 }, []);
27
28 React.useEffect(() => {
29 const root = document.documentElement;
30 if (theme === "system") {
31 root.removeAttribute("data-theme");
32 localStorage.removeItem("theme");
33 } else {
34 root.setAttribute("data-theme", theme);
35 localStorage.setItem("theme", theme);
36 }
37 }, [theme]);
38
39 React.useEffect(() => {
40 const media = window.matchMedia("(prefers-color-scheme: dark)");
41 const handler = () => {
42 if (theme === "system") {
43 // Trigger a re-render so any derived state updates
44 setTheme("system");
45 }
46 };
47 media.addEventListener("change", handler);
48 return () => media.removeEventListener("change", handler);
49 }, [theme]);
50
51 const cycleTheme = React.useCallback(() => {
52 setIsClicked(true);
53 setTimeout(() => setIsClicked(false), 150);
54 setTheme((prev) => (prev === "system" ? "light" : prev === "light" ? "dark" : "system"));
55 }, []);
56
57 const icon = theme === "system" ? "◐" : theme === "light" ? "○" : "●";
58 const resolved = getResolvedTheme(theme);
59
60 // Prevent hydration mismatch by rendering a placeholder until mounted
61 if (!mounted) {
62 return (
63 <button
64 className="flex items-center gap-2 px-3 py-2 text-sm font-mono text-(--text-muted) shrink-0"
65 disabled
66 aria-hidden="true"
67 >
68 <span>◐</span>
69 <span className="hidden sm:inline">system</span>
70 </button>
71 );
72 }
73
74 return (
75 <button
76 onClick={cycleTheme}
77 className="flex items-center gap-2 px-3 py-2 text-sm font-mono text-(--text-secondary) transition-colors duration-fast ease-default hover:text-(--accent-default) hover:bg-(--accent-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring-color) focus-visible:ring-offset-2 focus-visible:ring-offset-(--bg-secondary) shrink-0"
78 title={`Theme: ${resolved} (click to cycle)`}
79 aria-label={`Current theme: ${resolved}. Click to cycle.`}
80 >
81 <span
82 className={`text-(--accent-default) inline-block ${isClicked ? "motion-safe:scale-[1.3]" : ""} transition-transform duration-fast`}
83 >
84 {icon}
85 </span>
86 <span className="hidden sm:inline">{theme}</span>
87 </button>
88 );
89}