SurfSense/surfsense_web/components/theme/theme-toggle.tsx
Matt Van Horn 8cf957b301
fix(theme-toggle): use functional setIsDark in toggleTheme (#1247)
Closes #1247.

toggleTheme's previous implementation read isDark from the closure via
setIsDark(!isDark), which forced isDark into the useCallback dependency
array. As a result toggleTheme's reference changed on every click,
invalidating any downstream memoization.

Switched to the functional updater setIsDark((prev) => !prev) and
dropped isDark from the dependency list. The sibling setCrazyLightTheme
and setCrazyDarkTheme callbacks already use this pattern (they pass
concrete values to setIsDark without listing isDark in deps), so this
keeps the three theme callbacks consistent.

No observable behavior change — clicking the theme toggle still flips
isDark. The callback reference is now stable between clicks, which is
also safer under concurrent updates per React's standard guidance.
2026-04-20 01:58:20 -07:00

789 lines
21 KiB
TypeScript

"use client";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
// ///////////////////////////////////////////////////////////////////////////
// Types
export type AnimationVariant = "circle" | "rectangle" | "gif" | "polygon" | "circle-blur";
export type AnimationStart =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "center"
| "top-center"
| "bottom-center"
| "bottom-up"
| "top-down"
| "left-right"
| "right-left";
interface Animation {
name: string;
css: string;
}
// ///////////////////////////////////////////////////////////////////////////
// Helper functions
const getPositionCoords = (position: AnimationStart) => {
switch (position) {
case "top-left":
return { cx: "0", cy: "0" };
case "top-right":
return { cx: "40", cy: "0" };
case "bottom-left":
return { cx: "0", cy: "40" };
case "bottom-right":
return { cx: "40", cy: "40" };
case "top-center":
return { cx: "20", cy: "0" };
case "bottom-center":
return { cx: "20", cy: "40" };
case "bottom-up":
case "top-down":
case "left-right":
case "right-left":
return { cx: "20", cy: "20" };
}
};
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
if (variant === "circle-blur") {
if (start === "center") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
}
const positionCoords = getPositionCoords(start);
if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`);
}
const { cx, cy } = positionCoords;
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
}
if (start === "center") return;
if (variant === "rectangle") return "";
const positionCoords = getPositionCoords(start);
if (!positionCoords) {
throw new Error(`Invalid start position: ${start}`);
}
const { cx, cy } = positionCoords;
if (variant === "circle") {
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
}
return "";
};
const getTransformOrigin = (start: AnimationStart) => {
switch (start) {
case "top-left":
return "top left";
case "top-right":
return "top right";
case "bottom-left":
return "bottom left";
case "bottom-right":
return "bottom right";
case "top-center":
return "top center";
case "bottom-center":
return "bottom center";
case "bottom-up":
case "top-down":
case "left-right":
case "right-left":
return "center";
}
};
export const createAnimation = (
variant: AnimationVariant,
start: AnimationStart = "center",
blur = false,
url?: string
): Animation => {
const svg = generateSVG(variant, start);
const transformOrigin = getTransformOrigin(start);
if (variant === "rectangle") {
const getClipPath = (direction: AnimationStart) => {
switch (direction) {
case "bottom-up":
return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-down":
return {
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "left-right":
return {
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "right-left":
return {
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-left":
return {
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "top-right":
return {
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "bottom-left":
return {
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
case "bottom-right":
return {
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
default:
return {
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
};
}
};
const clipPath = getClipPath(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPath.from};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPath.to};
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPath.from};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPath.to};
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
if (variant === "circle" && start === "center") {
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at 50% 50%);
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(100.0% at 50% 50%);
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at 50% 50%);
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(100.0% at 50% 50%);
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
if (variant === "gif") {
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
::view-transition-new(root) {
mask: url('${url}') center / 0 no-repeat;
animation: scale 3s;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 3s;
}
@keyframes scale {
0% {
mask-size: 0;
}
10% {
mask-size: 50vmax;
}
90% {
mask-size: 50vmax;
}
100% {
mask-size: 2000vmax;
}
}`,
};
}
if (variant === "circle-blur") {
if (start === "center") {
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
mask: url('${svg}') center / 0 no-repeat;
mask-origin: content-box;
animation: scale 1s;
transform-origin: center;
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 1s;
transform-origin: center;
z-index: -1;
}
@keyframes scale {
to {
mask-size: 350vmax;
}
}
`,
};
}
return {
name: `${variant}-${start}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
mask-origin: content-box;
animation: scale 1s;
transform-origin: ${transformOrigin};
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale 1s;
transform-origin: ${transformOrigin};
z-index: -1;
}
@keyframes scale {
to {
mask-size: 350vmax;
}
}
`,
};
}
if (variant === "polygon") {
const getPolygonClipPaths = (position: AnimationStart) => {
switch (position) {
case "top-left":
return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
};
case "top-right":
return {
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
};
default:
return {
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
};
}
};
const clipPaths = getPolygonClipPaths(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 0.7s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPaths.darkFrom};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPaths.darkTo};
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: ${clipPaths.lightFrom};
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: ${clipPaths.lightTo};
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
// Handle circle variants with start positions using clip-path
if (variant === "circle" && start !== "center") {
const getClipPathPosition = (position: AnimationStart) => {
switch (position) {
case "top-left":
return "0% 0%";
case "top-right":
return "100% 0%";
case "bottom-left":
return "0% 100%";
case "bottom-right":
return "100% 100%";
case "top-center":
return "50% 0%";
case "bottom-center":
return "50% 100%";
default:
return "50% 50%";
}
};
const clipPosition = getClipPathPosition(start);
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-duration: 1s;
animation-timing-function: var(--expo-out);
}
::view-transition-new(root) {
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
z-index: -1;
}
.dark::view-transition-new(root) {
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
${blur ? "filter: blur(2px);" : ""}
}
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at ${clipPosition});
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(150.0% at ${clipPosition});
${blur ? "filter: blur(0px);" : ""}
}
}
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
from {
clip-path: circle(0% at ${clipPosition});
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
clip-path: circle(150.0% at ${clipPosition});
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
}
return {
name: `${variant}-${start}${blur ? "-blur" : ""}`,
css: `
::view-transition-group(root) {
animation-timing-function: var(--expo-in);
}
::view-transition-new(root) {
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
mask-origin: content-box;
animation: scale-${start}${blur ? "-blur" : ""} 1s;
transform-origin: ${transformOrigin};
${blur ? "filter: blur(2px);" : ""}
}
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: scale-${start}${blur ? "-blur" : ""} 1s;
transform-origin: ${transformOrigin};
z-index: -1;
}
@keyframes scale-${start}${blur ? "-blur" : ""} {
from {
${blur ? "filter: blur(8px);" : ""}
}
${blur ? "50% { filter: blur(4px); }" : ""}
to {
mask-size: 2000vmax;
${blur ? "filter: blur(0px);" : ""}
}
}
`,
};
};
// ///////////////////////////////////////////////////////////////////////////
// Custom hook for theme toggle functionality
export const useThemeToggle = ({
variant = "circle",
start = "center",
blur = false,
gifUrl = "",
}: {
variant?: AnimationVariant;
start?: AnimationStart;
blur?: boolean;
gifUrl?: string;
} = {}) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const [isDark, setIsDark] = useState(false);
// Sync isDark state with resolved theme after hydration
useEffect(() => {
setIsDark(resolvedTheme === "dark");
}, [resolvedTheme]);
const styleId = "theme-transition-styles";
const updateStyles = useCallback((css: string) => {
if (typeof window === "undefined") return;
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = styleId;
document.head.appendChild(styleElement);
}
styleElement.textContent = css;
}, []);
const toggleTheme = useCallback(() => {
setIsDark((prev) => !prev);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme(theme === "light" ? "dark" : "light");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyLightTheme = useCallback(() => {
setIsDark(false);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme("light");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyDarkTheme = useCallback(() => {
setIsDark(true);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
if (typeof window === "undefined") return;
const switchTheme = () => {
setTheme("dark");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazySystemTheme = useCallback(() => {
if (typeof window === "undefined") return;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setIsDark(prefersDark);
const animation = createAnimation(variant, start, blur, gifUrl);
updateStyles(animation.css);
const switchTheme = () => {
setTheme("system");
};
if (!document.startViewTransition) {
switchTheme();
return;
}
document.startViewTransition(switchTheme);
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
return {
isDark,
setIsDark,
toggleTheme,
setCrazyLightTheme,
setCrazyDarkTheme,
setCrazySystemTheme,
};
};
// ///////////////////////////////////////////////////////////////////////////
// Theme Toggle Button Component (Sun/Moon Style)
export const ThemeToggleButton = ({
className = "",
variant = "circle",
start = "center",
blur = false,
gifUrl = "",
}: {
className?: string;
variant?: AnimationVariant;
start?: AnimationStart;
blur?: boolean;
gifUrl?: string;
}) => {
const { isDark, toggleTheme } = useThemeToggle({
variant,
start,
blur,
gifUrl,
});
const clipId = useId();
const clipPathId = `theme-toggle-clip-${clipId}`;
return (
<button
type="button"
className={cn(
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
isDark ? "text-white" : "text-black",
className
)}
onClick={toggleTheme}
aria-label="Toggle theme"
>
<span className="sr-only">Toggle theme</span>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="currentColor"
strokeLinecap="round"
viewBox="0 0 32 32"
>
<clipPath id={clipPathId}>
<motion.path
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
transition={{ ease: "easeInOut", duration: 0.35 }}
d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
/>
</clipPath>
<g clipPath={`url(#${clipPathId})`}>
<motion.circle
animate={{ r: isDark ? 10 : 8 }}
transition={{ ease: "easeInOut", duration: 0.35 }}
cx="16"
cy="16"
/>
<motion.g
animate={{
rotate: isDark ? -100 : 0,
scale: isDark ? 0.5 : 1,
opacity: isDark ? 0 : 1,
}}
transition={{ ease: "easeInOut", duration: 0.35 }}
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M16 5.5v-4" />
<path d="M16 30.5v-4" />
<path d="M1.5 16h4" />
<path d="M26.5 16h4" />
<path d="m23.4 8.6 2.8-2.8" />
<path d="m5.7 26.3 2.9-2.9" />
<path d="m5.8 5.8 2.8 2.8" />
<path d="m23.4 23.4 2.9 2.9" />
</motion.g>
</g>
</svg>
</button>
);
};
// ///////////////////////////////////////////////////////////////////////////
// Backwards compatible export (alias for ThemeToggleButton with default settings)
export function ThemeTogglerComponent() {
return <ThemeToggleButton variant="circle" start="top-right" className="size-8" />;
}
/**
* Skiper 26 Theme_buttons_002 — React + CSS + transition view api https://developer.chrome.com/docs/web-platform/view-transitions/
* Original concept from rudrodip
* Inspired by from https://github.com/rudrodip/theme-toggle-effect
* We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership.
* These animations aren't associated with the rudrodip . They're independent recreations meant to study interaction design
*
* License & Usage:
* - Free to use and modify in both personal and commercial projects.
* - Attribution to Skiper UI is required when using the free version.
* - No attribution required with Skiper UI Pro.
*
* Feedback and contributions are welcome.
*
* Author: @gurvinder-singh02
* Website: https://gxuri.in
* Twitter: https://x.com/Gur__vi
*/