1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-06-30 23:21:29 -04:00

animated dark mode toggle 🌓

This commit is contained in:
2022-03-07 19:32:43 -05:00
parent 19394c1fb7
commit f5c8a7a21a
8 changed files with 229 additions and 31 deletions

View File

@ -7,10 +7,8 @@ export { default as ContactIcon } from "../../node_modules/feather-icons/dist/ic
export { default as DateIcon } from "../../node_modules/feather-icons/dist/icons/calendar.svg";
export { default as EditIcon } from "../../node_modules/feather-icons/dist/icons/edit.svg";
export { default as HomeIcon } from "../../node_modules/feather-icons/dist/icons/home.svg";
export { default as MoonIcon } from "../../node_modules/feather-icons/dist/icons/moon.svg";
export { default as NotesIcon } from "../../node_modules/feather-icons/dist/icons/edit-3.svg";
export { default as ProjectsIcon } from "../../node_modules/feather-icons/dist/icons/code.svg";
export { default as SunIcon } from "../../node_modules/feather-icons/dist/icons/sun.svg";
export { default as TagIcon } from "../../node_modules/feather-icons/dist/icons/tag.svg";
export { default as ViewsIcon } from "../../node_modules/feather-icons/dist/icons/eye.svg";

View File

@ -1,7 +1,9 @@
import { useEffect, useState, memo } from "react";
import { useEffect, memo } from "react";
import { useSpring, animated, Globals } from "@react-spring/web";
import { useTheme } from "../../hooks/use-theme";
import { useHasMounted } from "../../hooks/use-has-mounted";
import { usePrefersReducedMotion } from "../../hooks/use-prefers-reduced-motion";
import { styled } from "../../lib/styles/stitches.config";
import { SunIcon, MoonIcon } from "../Icons";
const Button = styled("button", {
border: 0,
@ -17,29 +19,139 @@ const Button = styled("button", {
});
export type ThemeToggleProps = {
id?: string;
className?: string;
};
const ThemeToggle = ({ className }: ThemeToggleProps) => {
const [mounted, setMounted] = useState(false);
const ThemeToggle = ({ id = "nav", className }: ThemeToggleProps) => {
const hasMounted = useHasMounted();
const prefersReducedMotion = usePrefersReducedMotion();
const { resolvedTheme, setTheme } = useTheme();
// default to light since `resolvedTheme` might be undefined
const safeTheme = resolvedTheme === "dark" ? "dark" : "light";
// accessibility: skip animation if user prefers reduced motion
useEffect(() => {
Globals.assign({
skipAnimation: prefersReducedMotion,
});
}, [prefersReducedMotion]);
// modified from https://jfelix.info/blog/using-react-spring-to-animate-svg-icons-dark-mode-toggle
const springProperties = {
light: {
svg: {
transform: "rotate(90deg)",
},
circle: {
r: 5,
},
mask: {
cx: "100%",
cy: "0%",
},
lines: {
opacity: 1,
},
},
dark: {
svg: {
transform: "rotate(40deg)",
},
circle: {
r: 9,
},
mask: {
cx: "50%",
cy: "23%",
},
lines: {
opacity: 0,
},
},
springConfig: { mass: 4, tension: 250, friction: 35 },
};
const { svg, circle, mask, lines } = springProperties[safeTheme];
const svgContainerProps = useSpring({
...svg,
config: springProperties.springConfig,
});
const centerCircleProps = useSpring({
...circle,
config: springProperties.springConfig,
});
const maskedCircleProps = useSpring({
...mask,
config: springProperties.springConfig,
});
const linesProps = useSpring({
...lines,
config: springProperties.springConfig,
});
// render a dummy button until we're fully mounted and self-aware
useEffect(() => setMounted(true), []);
if (!mounted) {
if (!hasMounted) {
return (
<Button aria-hidden={true}>
<SunIcon className={className} />
<Button aria-hidden={true} disabled={true}>
<div className={className} />
</Button>
);
}
return (
<Button
onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}
title={resolvedTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
onClick={() => setTheme(safeTheme === "light" ? "dark" : "light")}
title={safeTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
aria-label={safeTheme === "light" ? "Toggle Dark Mode" : "Toggle Light Mode"}
>
{resolvedTheme === "light" ? <SunIcon className={className} /> : <MoonIcon className={className} />}
<animated.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
...svgContainerProps,
}}
className={className}
>
<mask id={`moon-mask-${id}`}>
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<animated.circle
r="9"
fill="black"
// @ts-ignore
style={maskedCircleProps}
/>
</mask>
{/* circle shared by both the sun and crescent moon */}
<animated.circle
cx="12"
cy="12"
fill="currentColor"
mask={`url(#moon-mask-${id})`}
// @ts-ignore
style={centerCircleProps}
/>
{/* sunrays pulled from https://github.com/feathericons/feather/blob/734f3f51144e383cfdc6d0916831be8d1ad2a749/icons/sun.svg?short_path=fea872c#L13 */}
<animated.g stroke="currentColor" style={linesProps}>
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</animated.g>
</animated.svg>
</Button>
);
};