2025-04-07 23:47:06 -07:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-30 21:53:10 -07:00
|
|
|
import { motion } from "motion/react";
|
2025-07-27 10:41:15 -07:00
|
|
|
import { useTheme } from "next-themes";
|
2026-02-03 21:51:35 -08:00
|
|
|
import { useCallback, useEffect, useId, useState } from "react";
|
2025-04-07 23:47:06 -07:00
|
|
|
|
2026-02-03 21:51:35 -08:00
|
|
|
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(!isDark);
|
|
|
|
|
|
|
|
|
|
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, isDark]);
|
|
|
|
|
|
|
|
|
|
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)
|
2025-04-07 23:47:06 -07:00
|
|
|
export function ThemeTogglerComponent() {
|
2026-02-03 21:51:35 -08:00
|
|
|
return (
|
|
|
|
|
<ThemeToggleButton
|
|
|
|
|
variant="circle"
|
|
|
|
|
start="top-right"
|
|
|
|
|
className="size-8"
|
|
|
|
|
/>
|
|
|
|
|
);
|
2025-04-07 23:47:06 -07:00
|
|
|
}
|
2026-02-03 21:51:35 -08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|