Skip to content

Commit 589c8d2

Browse files
authored
Adds a theme toggle component (#21)
* adds accessible theme toggle component
1 parent 2278b90 commit 589c8d2

File tree

8 files changed

+399
-416
lines changed

8 files changed

+399
-416
lines changed

components/header.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import constants from "../constants";
22
import { Link } from "./link";
33
import Logo from "./logo";
4+
import ThemeToggle from "./theme-toggle/theme-toggle";
45

56
interface HeaderProps {
67
variant?: "minimal";
@@ -37,6 +38,7 @@ export default function Header({ variant }: HeaderProps) {
3738
</li>
3839
</>
3940
) : null}
41+
<ThemeToggle />
4042
</ol>
4143
</nav>
4244
</header>

components/theme-toggle/moon.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default function Moon(props) {
2+
return (
3+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
4+
<path
5+
d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"
6+
fill="currentColor"
7+
/>
8+
</svg>
9+
);
10+
}

components/theme-toggle/sun.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default function Sun(props) {
2+
return (
3+
<svg
4+
xmlns="http://www.w3.org/2000/svg"
5+
viewBox="0 0 24 24"
6+
strokeLinecap="round"
7+
{...props}
8+
>
9+
<circle cx="12" cy="12" r="6" fill="currentColor" />
10+
<g stroke="currentColor" strokeWidth="2">
11+
<line x1="12" y1="1" x2="12" y2="3" />
12+
<line x1="12" y1="21" x2="12" y2="23" />
13+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
14+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
15+
<line x1="1" y1="12" x2="3" y2="12" />
16+
<line x1="21" y1="12" x2="23" y2="12" />
17+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
18+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
19+
</g>
20+
</svg>
21+
);
22+
}
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import Moon from "./moon";
3+
import Sun from "./sun";
4+
5+
const resolveColorScheme = (isDark) => (isDark ? "dark" : "light");
6+
7+
export default function ThemeToggle() {
8+
// This is required to prevent the animation from playing on
9+
// initial mount as well as update the aria-label.
10+
const changedRef = useRef(false);
11+
const [colorScheme, setColorScheme] = useState(null);
12+
const handleChange = useCallback(
13+
(ev) => {
14+
const colorScheme = resolveColorScheme(ev.target.checked);
15+
if (!changedRef.current) {
16+
changedRef.current = true;
17+
}
18+
setColorScheme(colorScheme);
19+
// Notify the document so it will update the theme.
20+
// Refer to _document.js for implementation.
21+
window.dispatchEvent(
22+
new CustomEvent("colorSchemeChange", {
23+
detail: { colorScheme, persist: true },
24+
})
25+
);
26+
},
27+
[setColorScheme]
28+
);
29+
30+
useEffect(() => {
31+
const query = window.matchMedia("(prefers-color-scheme: dark)");
32+
const listener = ({ matches }) => {
33+
setColorScheme(resolveColorScheme(matches));
34+
};
35+
36+
query.addEventListener("change", listener);
37+
38+
// Sets the initial color scheme based on user's preference.
39+
// Doing this here so that it's ran on client side only.
40+
setColorScheme(
41+
resolveColorScheme(document.documentElement.classList.contains("dark"))
42+
);
43+
44+
return () => {
45+
query.removeEventListener("change", listener);
46+
};
47+
}, [setColorScheme]);
48+
const hasThemeChanged = changedRef.current;
49+
50+
return (
51+
<div>
52+
<input
53+
type="checkbox"
54+
title="Toggle between light & dark modes"
55+
// Screen readers should announce changes to aria-label
56+
// It's set to auto for the first time since the user didn't
57+
// actually change it and we set the theme automatically based
58+
// on preference.
59+
aria-label={hasThemeChanged ? colorScheme : "auto"}
60+
aria-live="polite"
61+
id="theme-toggle"
62+
className={`sr-only peer`}
63+
onChange={handleChange}
64+
data-changed={hasThemeChanged}
65+
checked={colorScheme === "dark"}
66+
/>
67+
<div
68+
className={
69+
"overflow-hidden rounded-full w-8 h-8 border-2 isolate " +
70+
"dark:border-gray-200 border-slate-300 bg-gray-50/10 " +
71+
"peer-focus-visible:outline-2 peer-focus-visible:outline-white " +
72+
"peer-focus-visible:outline peer-focus-visible:border-sky-600"
73+
}
74+
>
75+
<label
76+
htmlFor="theme-toggle"
77+
className={
78+
'[[aria-label="light"]~*_&]:motion-safe:animate-rotate-0-180 ' +
79+
'[[aria-label="dark"]~*_&]:motion-safe:animate-rotate-180-360 ' +
80+
"block h-full w-[200%] whitespace-nowrap rotate-180 origin-center " +
81+
"select-none dark:rotate-0"
82+
}
83+
>
84+
<Moon className="h-full p-1.5 inline-block" aria-hidden="true" />
85+
<Sun className="h-full p-1.5 inline-block" aria-hidden="true" />
86+
</label>
87+
</div>
88+
</div>
89+
);
90+
}

0 commit comments

Comments
 (0)