mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-04-26 07:25:21 -04:00
animated dark mode toggle 🌓
This commit is contained in:
parent
19394c1fb7
commit
f5c8a7a21a
@ -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";
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
11
hooks/use-has-mounted.ts
Normal file
11
hooks/use-has-mounted.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useHasMounted = () => {
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
return hasMounted;
|
||||
};
|
40
hooks/use-prefers-reduced-motion.ts
Normal file
40
hooks/use-prefers-reduced-motion.ts
Normal file
@ -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;
|
||||
};
|
@ -238,8 +238,6 @@ const ThemeScript = memo(
|
||||
<script
|
||||
key="next-themes-script"
|
||||
dangerouslySetInnerHTML={{
|
||||
// These are minified via Terser and then updated by hand, don't recommend
|
||||
// prettier-ignore
|
||||
__html: `!function(){${optimization}${updateDOM(forcedTheme)}}()`,
|
||||
}}
|
||||
/>
|
||||
@ -247,8 +245,14 @@ const ThemeScript = memo(
|
||||
<script
|
||||
key="next-themes-script"
|
||||
dangerouslySetInnerHTML={{
|
||||
// prettier-ignore
|
||||
__html: `!function(){try {${optimization}var e=localStorage.getItem("${storageKey}");${!defaultSystem ? updateDOM(defaultTheme) + ";" : ""}if("system"===e||(!e&&${defaultSystem})){var t="${MEDIA}",m=window.matchMedia(t);m.media!==t||m.matches?${updateDOM("dark")}:${updateDOM("light")}}else if(e) ${value ? `var x=${JSON.stringify(value)};` : ""}${updateDOM(value ? "x[e]" : "e", true)}}catch(e){}}()`,
|
||||
__html: `!function(){try{${optimization}var e=localStorage.getItem("${storageKey}");${
|
||||
!defaultSystem ? updateDOM(defaultTheme) + ";" : ""
|
||||
}if("system"===e||(!e&&${defaultSystem})){var t="${MEDIA}",m=window.matchMedia(t);m.media!==t||m.matches?${updateDOM(
|
||||
"dark"
|
||||
)}:${updateDOM("light")}}else if(e){${value ? `var x=${JSON.stringify(value)}` : ""}}${updateDOM(
|
||||
value ? "x[e]" : "e",
|
||||
true
|
||||
)}}catch(e){}}()`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -157,20 +157,6 @@ export const globalStyles = globalCss(
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// reduced motion preference:
|
||||
// https://web.dev/prefers-reduced-motion/#(bonus)-forcing-reduced-motion-on-all-websites
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"*, ::before, ::after": {
|
||||
animationDelay: "-1ms !important",
|
||||
animationDuration: "1ms !important",
|
||||
animationIterationCount: "1 !important",
|
||||
backgroundAttachment: "initial !important",
|
||||
scrollBehavior: "auto !important",
|
||||
transitionDuration: "0s !important",
|
||||
transitionDelay: "0s !important",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -27,6 +27,7 @@
|
||||
"@novnc/novnc": "github:novnc/noVNC#679b45fa3b453c7cf32f4b4455f4814818ecf161",
|
||||
"@octokit/graphql": "^4.8.0",
|
||||
"@primer/octicons": "^17.0.0",
|
||||
"@react-spring/web": "^9.4.3",
|
||||
"@sentry/node": "^6.18.1",
|
||||
"@sentry/tracing": "^6.18.1",
|
||||
"@stitches/react": "^1.2.7",
|
||||
|
46
yarn.lock
46
yarn.lock
@ -1314,6 +1314,52 @@
|
||||
dependencies:
|
||||
object-assign "^4.1.1"
|
||||
|
||||
"@react-spring/animated@~9.4.3-beta.0":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.3.tgz#2f8d2b50dfc1975fa490ed3bc03f5ad865180866"
|
||||
integrity sha512-hKKmeXPoGpJ/zrG/RC8stwW8PmMH0BbewHD8aUPLbyzD9fNvZEJ0mjKmOI0CcSwMpb43kuwY2nX3ZJVImPQCoQ==
|
||||
dependencies:
|
||||
"@react-spring/shared" "~9.4.3-beta.0"
|
||||
"@react-spring/types" "~9.4.3-beta.0"
|
||||
|
||||
"@react-spring/core@~9.4.3-beta.0":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.4.3.tgz#95c883fa53ff534ff882ba42f863a26a26a6a1c8"
|
||||
integrity sha512-Jr6/GjHwXYxAtttcYDXOtH36krO0XGjYaSsGR6g+vOUO4y0zAPPXoAwpK6vS7Haip5fRwk7rMdNG+OzU7bB4Bg==
|
||||
dependencies:
|
||||
"@react-spring/animated" "~9.4.3-beta.0"
|
||||
"@react-spring/rafz" "~9.4.3-beta.0"
|
||||
"@react-spring/shared" "~9.4.3-beta.0"
|
||||
"@react-spring/types" "~9.4.3-beta.0"
|
||||
|
||||
"@react-spring/rafz@~9.4.3-beta.0":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.4.3.tgz#0d578072c9692ef5ab74a3b1d49c1432dce32ab6"
|
||||
integrity sha512-KnujiZNIHzXsRq1D4tVbCajl8Lx+e6vtvUk7o69KbuneSpEgil9P/x3b+hMDk8U0NHGhJjzhU7723/CNsQansA==
|
||||
|
||||
"@react-spring/shared@~9.4.3-beta.0":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.4.3.tgz#86e03ddd47911ba89be1d0f5a6d11966e305ee04"
|
||||
integrity sha512-mB1UUD/pl1LzaY0XeNWZtvJzxMa8gLQf02nY12HAz4Rukm9dFRj0jeYwQYLdfYLsGFo1ldvHNurun6hZMG7kiQ==
|
||||
dependencies:
|
||||
"@react-spring/rafz" "~9.4.3-beta.0"
|
||||
"@react-spring/types" "~9.4.3-beta.0"
|
||||
|
||||
"@react-spring/types@~9.4.3-beta.0":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.4.3.tgz#8926d7a09812374127b1f8a904a755c7579124e6"
|
||||
integrity sha512-dzJrPvUc42K2un9y6D1IsrPQO5tKsbWwUo+wsATnXjG3ePWyuDBIOMJuPe605NhIXUmPH+Vik2wMoZz06hD1uA==
|
||||
|
||||
"@react-spring/web@^9.4.3":
|
||||
version "9.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.4.3.tgz#b59c1491de344545590598b7fde52b607c4e5d10"
|
||||
integrity sha512-llKve/uJ73JVagBAVvA74S/LfZP4oSB3XP1qmggSUNXzPZZo5ylIMrs55PxpLyxgzzihuhDU5N17ct3ATViOHw==
|
||||
dependencies:
|
||||
"@react-spring/animated" "~9.4.3-beta.0"
|
||||
"@react-spring/core" "~9.4.3-beta.0"
|
||||
"@react-spring/shared" "~9.4.3-beta.0"
|
||||
"@react-spring/types" "~9.4.3-beta.0"
|
||||
|
||||
"@rushstack/eslint-patch@1.0.8":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.8.tgz#be3e914e84eacf16dbebd311c0d0b44aa1174c64"
|
||||
|
Loading…
x
Reference in New Issue
Block a user