Files
6af4e76d-33ee-44c0-8b60-c14…/src/components/sections/about/AboutCursorTrail.tsx
2026-07-02 05:55:20 +00:00

198 lines
6.7 KiB
TypeScript

import { useRef, useEffect } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Button from "@/components/ui/Button";
gsap.registerPlugin(ScrollTrigger);
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
type TrailMedia = { imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never };
type AboutCursorTrailProps = {
tag: string;
title: string;
media: TrailMedia[];
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const AboutCursorTrail = ({
tag,
title,
media,
primaryButton,
secondaryButton,
textAnimation,
}: AboutCursorTrailProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const mm = gsap.matchMedia();
mm.add("(min-width: 769px)", () => {
const images = Array.from(wrapper.querySelectorAll<HTMLElement>('[data-trail="item"]'));
if (!images.length) return;
let index = 0;
let lastX = 0;
let lastY = 0;
let fadeTimeout: ReturnType<typeof setTimeout>;
let isActive = false;
const threshold = window.innerWidth / 15;
function onMove(e: MouseEvent) {
if (!isActive) return;
const rect = wrapper!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Bounds check - ignore if mouse is outside the wrapper
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
if (Math.hypot(x - lastX, y - lastY) > threshold) {
if (index >= images.length) {
gsap.to(images[(index - images.length) % images.length], { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" });
}
const img = images[index % images.length];
gsap.set(img, { x: x - img.offsetWidth / 2, y: y - img.offsetHeight / 2, zIndex: index, force3D: true });
gsap.fromTo(img, { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.2, overwrite: true });
lastX = x;
lastY = y;
index++;
clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => images.forEach(img =>
gsap.to(img, { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" })
), 350);
}
}
function onMouseLeave() {
clearTimeout(fadeTimeout);
images.forEach(img =>
gsap.to(img, { autoAlpha: 0, scale: 0.2, duration: 0.5, ease: "expo.out" })
);
// Reset position tracking so next entry starts fresh
lastX = 0;
lastY = 0;
}
function startTrail() {
if (isActive || !wrapper) return;
isActive = true;
wrapper.addEventListener("mousemove", onMove);
wrapper.addEventListener("mouseleave", onMouseLeave);
}
function stopTrail() {
if (!isActive || !wrapper) return;
isActive = false;
wrapper.removeEventListener("mousemove", onMove);
wrapper.removeEventListener("mouseleave", onMouseLeave);
clearTimeout(fadeTimeout);
images.forEach(img => {
gsap.killTweensOf(img);
gsap.set(img, { autoAlpha: 0, scale: 1 });
});
}
const trigger = ScrollTrigger.create({
trigger: wrapper,
start: "top bottom",
end: "bottom top",
onEnter: startTrail,
onEnterBack: startTrail,
onLeave: stopTrail,
onLeaveBack: stopTrail,
});
return () => {
stopTrail();
trigger.kill();
};
});
mm.add("(max-width: 768px)", () => {
const images = Array.from(wrapper.querySelectorAll<HTMLElement>('[data-trail="item"]'));
if (!images.length) return;
let index = 0;
let lastScrollY = window.scrollY;
let fadeTimeout: ReturnType<typeof setTimeout>;
const threshold = 100;
function onScroll() {
const rect = wrapper!.getBoundingClientRect();
if (rect.top > window.innerHeight || rect.bottom < 0) return;
const scrollDelta = Math.abs(window.scrollY - lastScrollY);
if (scrollDelta > threshold) {
if (index >= images.length) {
gsap.to(images[(index - images.length) % images.length], { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" });
}
const img = images[index % images.length];
const x = Math.random() * (rect.width - img.offsetWidth);
const y = Math.random() * (rect.height - img.offsetHeight);
gsap.set(img, { x, y, zIndex: index, force3D: true });
gsap.fromTo(img, { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.2, overwrite: true });
lastScrollY = window.scrollY;
index++;
clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => images.forEach(img =>
gsap.to(img, { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" })
), 500);
}
}
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
clearTimeout(fadeTimeout);
images.forEach(img => {
gsap.killTweensOf(img);
gsap.set(img, { autoAlpha: 0, scale: 1 });
});
};
});
return () => mm.revert();
}, []);
return (
<section aria-label="About section" className="relative py-60">
<div ref={wrapperRef} data-trail="wrapper" className="w-full h-full absolute inset-0 z-0">
{media.map((item, i) => (
<div key={i} data-trail="item" className="invisible rounded w-[15em] h-[20em] absolute overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="object-cover w-full h-full" />
</div>
))}
</div>
<div className="relative z-10 flex flex-col items-center gap-2 w-content-width mx-auto text-center pointer-events-none">
<h3 className="text-3xl md:text-5xl font-medium">{tag}</h3>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-7xl md:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3 pointer-events-auto">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" />
</div>
</div>
</section>
);
};
export default AboutCursorTrail;