mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 08:46:22 +02:00
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.
789 lines
21 KiB
TypeScript
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
|
|
*/
|