storytellurx-v1/app/components/vectors/zipper.tsx
Elias Rhouzlane a82706a764 feat: Add Lottie to SVG conversion utility and integrate zipper animation
- Created a new utility script `lottie-to-svg.ts` to convert Lottie animations to SVG format using `bodymovin-to-smil`.
- Added a new Lottie animation file `zipper.json` for the zipper animation.
- Generated the corresponding SVG file `zipper.svg` from the Lottie animation.
- Updated `deno.json` to include new dependencies and references for the zipper animation.
- Modified `main.css` to introduce new color variables and styles for the zipper animation.
2025-07-01 04:23:37 +02:00

178 lines
6.4 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
export const ZipperSVG: React.FC<{ className?: string }> = ({ className }) => {
const zipperTeethDefId = useId();
const zipperPullId = useId();
const hoverTriggerId = useId().replace("»", "x").replace("«", "z");
const animatedZipperPathValues = [
"M -355 -450 C -354.6 -65.1 -355 368.5 -1.35 370 C 5.3 370 0 370 0 370 H -355 V -450",
"M -355 -450 C -115 -161 -1.53 68.83 -1.35 370 C -1.06 295.48 0 370 0 370 H -355 V -450",
"M-262.6 -450 C-90.08,-229.72 -1.53,-81.25 -1.35,4.92 C-1.06,145.39 0,370 0,370 H -355 V -450",
"M-166.48 -450 C-61.44,-299.83 -1.53,-231.33 -1.35,-145.16 C-1.06,-4.69 0,370 0,370 H -355 V -450",
"M-89.81 -450 C-32.97,-354.91 -1.53,-338.53 -1.35,-252.36 C-1.06,-111.89 0,370 0,370 H -355 V -450",
"M-45.9 -450 C-17.98,-385.45 -1.53,-402.85 -1.35,-316.68 C-1.06,-176.2 0,370 0,370 H -355 V -450",
"M-18.3 -450 C-9.66,-401.82 -1.53,-445.73 -1.35,-359.56 C-1.06,-219.09 0,370 0,370 H -355 V -450",
"M-1 -450 C-1,-405.25 -1.03,-401.38 -0.98,-380.87 C-0.62,-239.38 0,370 0,370 H -355 V -450",
];
const zippedPath = animatedZipperPathValues.at(-1)!;
const animationDuration = "0.8s";
const animationEasing = "0.4 0 0.2 1"; // A standard ease-in-out curve
const unzipTeethRef = useRef<SVGAnimateElement>(null);
const zipTeethRef = useRef<SVGAnimateElement>(null);
const unzipPullRef = useRef<SVGAnimateTransformElement>(null);
const zipPullRef = useRef<SVGAnimateTransformElement>(null);
const [isZipped, setIsZipped] = useState(true);
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
}, []);
const handleToggle = () => {
if (!isTouchDevice) return;
if (isZipped) {
// Trigger the "unzip" animations
unzipTeethRef.current?.beginElement();
unzipPullRef.current?.beginElement();
} else {
// Trigger the "zip" animations
zipTeethRef.current?.beginElement();
zipPullRef.current?.beginElement();
}
setIsZipped(!isZipped); // Toggle the state
};
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
preserveAspectRatio="none"
viewBox="150 100 700 500"
className={className}
>
<defs>
{/* --- Path for the Zipper Teeth (starts in the zipped-up state) --- */}
<path
id={zipperTeethDefId}
stroke="var(--primary)"
strokeLinecap="round"
strokeLinejoin="round"
fill="var(--background)"
vectorEffect="non-scaling-stroke"
strokeWidth="1"
strokeOpacity="1"
d={zippedPath} // Start with the final, "zipped up" path
/>
{/* --- HOVER ANIMATIONS (Unzipping) --- */}
<animate
ref={unzipTeethRef}
xlinkHref={`#${zipperTeethDefId}`}
begin={isTouchDevice ? "indefinite" : `${hoverTriggerId}.mouseover`}
dur={animationDuration}
fill="freeze"
attributeName="d"
// Animate from zipped to unzipped
values={animatedZipperPathValues.toReversed().join(";")}
calcMode="spline"
keySplines={`${animationEasing};`.repeat(
animatedZipperPathValues.length - 1,
).slice(0, -1)}
/>
<animateTransform
ref={unzipPullRef}
xlinkHref={`#${zipperPullId}`}
begin={isTouchDevice ? "indefinite" : `${hoverTriggerId}.mouseover`}
dur={animationDuration}
fill="freeze"
attributeName="transform"
type="translate"
additive="replace" // Use replace to override the initial transform
// Animate from top (zipped) to bottom (unzipped)
values="8 -1220; 8 160.329"
calcMode="spline"
keySplines={animationEasing}
/>
{/* --- MOUSEOUT ANIMATIONS (Zipping back up) --- */}
<animate
ref={zipTeethRef}
xlinkHref={`#${zipperTeethDefId}`}
begin={isTouchDevice ? "indefinite" : `${hoverTriggerId}.mouseout`}
dur={animationDuration}
fill="freeze"
attributeName="d"
// Animate from unzipped back to zipped
values={animatedZipperPathValues.join(";")}
calcMode="spline"
keySplines={`${animationEasing};`.repeat(
animatedZipperPathValues.length - 1,
).slice(0, -1)}
/>
<animateTransform
ref={zipPullRef}
xlinkHref={`#${zipperPullId}`}
begin={isTouchDevice ? "indefinite" : `${hoverTriggerId}.mouseout`}
dur={animationDuration}
fill="freeze"
attributeName="transform"
type="translate"
additive="replace"
// Animate from bottom (unzipped) back to top (zipped)
values="8 160.329; 8 -1220"
calcMode="spline"
keySplines={animationEasing}
/>
</defs>
<g id="_R_G">
{/* Zipper Teeth (Left and Right) */}
<g transform="translate(494.7, 537)">
<use xlinkHref={`#${zipperTeethDefId}`} />
</g>
<g transform="scale(-1, 1) translate(-1000, 0)">
<g transform="translate(494.7, 537)">
<use xlinkHref={`#${zipperTeethDefId}`} />
</g>
</g>
{/* Zipper Pull - wrapped in two groups to handle transforms cleanly */}
<g transform="translate(494.7, 537) scale(0.38 0.38)">
<g id={zipperPullId} transform="translate(8 -1220)">
<path
fill="var(--accent-foreground)"
stroke="#fff"
d="M52 156H-54l-10 242c-1 33 21 46 39 49s42 0 56-1c15-2 33-22 32-42L52 156z"
/>
<path
fill="var(--background)"
d="M41 191v42c0 11-9 20-20 20h-45c-11 0-20-9-20-20v-42c0-12 9-20 20-20h45c11 0 20 8 20 20zM-1 346c23 0 43 18 43 40s-20 40-43 40c-24 0-43-18-43-40s19-40 43-40z"
/>
<path
fill="var(--accent-foreground)"
d="M29 1v197c0 6-5 11-11 11h-38c-6 0-10-5-10-11V1c0-6 4-10 10-10h38c6 0 11 4 11 10z"
/>
</g>
</g>
</g>
{/* INVISIBLE HOVER AREA */}
<rect
id={hoverTriggerId}
x="150"
y="100"
width="700"
height="500"
fill="transparent"
cursor="pointer"
onClick={handleToggle}
/>
</svg>
);
};