From f5c8a7a21aa1adb53041e0681e355fc2f6270856 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Mon, 7 Mar 2022 19:32:43 -0500 Subject: [PATCH] =?UTF-8?q?animated=20dark=20mode=20toggle=20=F0=9F=8C=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/Icons/index.ts | 2 - components/ThemeToggle/ThemeToggle.tsx | 134 +++++++++++++++++++++++-- hooks/use-has-mounted.ts | 11 ++ hooks/use-prefers-reduced-motion.ts | 40 ++++++++ hooks/use-theme.tsx | 12 ++- lib/styles/stitches.config.ts | 14 --- package.json | 1 + yarn.lock | 46 +++++++++ 8 files changed, 229 insertions(+), 31 deletions(-) create mode 100644 hooks/use-has-mounted.ts create mode 100644 hooks/use-prefers-reduced-motion.ts diff --git a/components/Icons/index.ts b/components/Icons/index.ts index 75210d1a..7dc78f4d 100644 --- a/components/Icons/index.ts +++ b/components/Icons/index.ts @@ -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"; diff --git a/components/ThemeToggle/ThemeToggle.tsx b/components/ThemeToggle/ThemeToggle.tsx index 738a3c7b..aafae834 100644 --- a/components/ThemeToggle/ThemeToggle.tsx +++ b/components/ThemeToggle/ThemeToggle.tsx @@ -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 ( - ); } return ( ); }; diff --git a/hooks/use-has-mounted.ts b/hooks/use-has-mounted.ts new file mode 100644 index 00000000..b452719e --- /dev/null +++ b/hooks/use-has-mounted.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +export const useHasMounted = () => { + const [hasMounted, setHasMounted] = useState(false); + + useEffect(() => { + setHasMounted(true); + }, []); + + return hasMounted; +}; diff --git a/hooks/use-prefers-reduced-motion.ts b/hooks/use-prefers-reduced-motion.ts new file mode 100644 index 00000000..7c9ad07b --- /dev/null +++ b/hooks/use-prefers-reduced-motion.ts @@ -0,0 +1,40 @@ +// SSR-safe reduced motion hook: +// https://www.joshwcomeau.com/react/prefers-reduced-motion/#ssr-safety + +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: no-preference)"; + +export const usePrefersReducedMotion = (): boolean => { + // default to no animations on server-side + const [prefersReducedMotion, setPrefersReducedMotion] = useState(true); + + useEffect(() => { + // this can now be safely set for the first time on the client-side + setPrefersReducedMotion(!window.matchMedia(QUERY).matches); + + // register a listener for changes + const listener = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(!event.matches); + }; + + const mediaQueryList = window.matchMedia(QUERY); + if (mediaQueryList.addEventListener) { + mediaQueryList.addEventListener("change", listener); + } else { + // support deprecated listener API + mediaQueryList.addListener(listener); + } + + // clean up the event listener + return () => { + if (mediaQueryList.removeEventListener) { + mediaQueryList.removeEventListener("change", listener); + } else { + mediaQueryList.removeListener(listener); + } + }; + }, [setPrefersReducedMotion]); + + return prefersReducedMotion; +}; diff --git a/hooks/use-theme.tsx b/hooks/use-theme.tsx index 6af52fa5..6ea561c1 100644 --- a/hooks/use-theme.tsx +++ b/hooks/use-theme.tsx @@ -238,8 +238,6 @@ const ThemeScript = memo(