forked from boos/BoosAPI
326 lines
9.7 KiB
TypeScript
326 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import { type ReactNode, useRef } from "react";
|
|
import gsap from "gsap";
|
|
import { useGSAP } from "@gsap/react";
|
|
import { CustomEase } from "gsap/CustomEase";
|
|
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import { SplitText } from "gsap/SplitText";
|
|
|
|
gsap.registerPlugin(useGSAP, ScrollTrigger, ScrollToPlugin, SplitText, CustomEase);
|
|
|
|
const bossEase = CustomEase.create("boss-ease", "M0,0 C0.18,0.84 0.32,1 1,1");
|
|
|
|
type BossAiMotionProps = {
|
|
children: ReactNode;
|
|
};
|
|
|
|
export function BossAiMotion({ children }: BossAiMotionProps) {
|
|
const scopeRef = useRef<HTMLDivElement>(null);
|
|
|
|
useGSAP(
|
|
() => {
|
|
const root = scopeRef.current;
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
gsap.set("[data-motion]", { autoAlpha: 1 });
|
|
gsap.set("[data-motion-line]", { transformOrigin: "center" });
|
|
|
|
const matchMedia = gsap.matchMedia();
|
|
|
|
matchMedia.add("(prefers-reduced-motion: reduce)", () => {
|
|
gsap.set("[data-motion], [data-motion-dot], [data-motion-orbit], [data-motion-line]", {
|
|
autoAlpha: 1,
|
|
clearProps: "transform",
|
|
});
|
|
});
|
|
|
|
matchMedia.add("(prefers-reduced-motion: no-preference)", () => {
|
|
const heroTitle = root.querySelector<HTMLElement>(".hero-copy h1");
|
|
const splitTitle = heroTitle
|
|
? SplitText.create(heroTitle, {
|
|
type: "words",
|
|
aria: "auto",
|
|
wordsClass: "hero-word",
|
|
})
|
|
: null;
|
|
|
|
const intro = gsap.timeline({ defaults: { ease: bossEase } });
|
|
intro
|
|
.from("[data-motion='nav']", { autoAlpha: 0, y: -18, duration: 0.7 })
|
|
.from(".section-kicker", { autoAlpha: 0, y: 18, duration: 0.56 }, "-=0.18")
|
|
.from(
|
|
splitTitle?.words ?? ".hero-copy h1",
|
|
{
|
|
autoAlpha: 0,
|
|
yPercent: 92,
|
|
rotateX: -18,
|
|
transformOrigin: "50% 100%",
|
|
duration: 0.82,
|
|
stagger: { each: 0.035, from: "start" },
|
|
},
|
|
"-=0.28",
|
|
)
|
|
.from(".hero-copy p", { autoAlpha: 0, y: 22, duration: 0.7 }, "-=0.5")
|
|
.from(".hero-actions > *", { autoAlpha: 0, y: 14, duration: 0.46, stagger: 0.08 }, "-=0.42")
|
|
.from(".hero-proof > div", { autoAlpha: 0, y: 16, duration: 0.5, stagger: 0.07 }, "-=0.28")
|
|
.from("[data-motion='hero-console']", { autoAlpha: 0, y: 34, rotateX: 5, duration: 0.9 }, "-=0.78")
|
|
.from("[data-motion='route-row']", { autoAlpha: 0, x: 24, duration: 0.55, stagger: 0.08 }, "-=0.48")
|
|
.from("[data-motion='node']", { autoAlpha: 0, scale: 0.94, duration: 0.45, stagger: 0.08 }, "-=0.3");
|
|
|
|
gsap.to("[data-motion-orbit]", {
|
|
rotate: 360,
|
|
duration: 28,
|
|
ease: "none",
|
|
repeat: -1,
|
|
});
|
|
|
|
gsap.to("[data-motion-dot]", {
|
|
scale: 1.18,
|
|
autoAlpha: 0.72,
|
|
duration: 1.4,
|
|
ease: "sine.inOut",
|
|
repeat: -1,
|
|
yoyo: true,
|
|
stagger: 0.24,
|
|
});
|
|
|
|
gsap.to("[data-motion-line]", {
|
|
strokeDashoffset: -160,
|
|
duration: 5.8,
|
|
ease: "none",
|
|
repeat: -1,
|
|
stagger: 0.28,
|
|
});
|
|
|
|
const heroSection = root.querySelector<HTMLElement>(".hero-section");
|
|
const heroConsole = root.querySelector<HTMLElement>(".hero-console");
|
|
const routeRows = gsap.utils.toArray<HTMLElement>(".route-row", root);
|
|
const consoleTiles = gsap.utils.toArray<HTMLElement>(".console-metrics > div, .mini-grid > div", root);
|
|
|
|
if (heroSection && heroConsole) {
|
|
gsap.set(heroConsole, {
|
|
"--scan-y": "12%",
|
|
"--scan-opacity": 0.16,
|
|
"--grid-opacity": 0.16,
|
|
} as gsap.TweenVars);
|
|
|
|
gsap.to(heroConsole, {
|
|
yPercent: -4,
|
|
scale: 0.985,
|
|
ease: "none",
|
|
scrollTrigger: {
|
|
trigger: heroSection,
|
|
start: "top top",
|
|
end: "bottom top",
|
|
scrub: 0.85,
|
|
},
|
|
});
|
|
|
|
gsap.to(heroConsole, {
|
|
"--scan-y": "88%",
|
|
"--scan-opacity": 0.38,
|
|
"--grid-opacity": 0.34,
|
|
ease: "none",
|
|
scrollTrigger: {
|
|
trigger: heroSection,
|
|
start: "top 18%",
|
|
end: "bottom 18%",
|
|
scrub: 0.7,
|
|
},
|
|
} as gsap.TweenVars);
|
|
|
|
gsap.to(routeRows, {
|
|
x: (index) => (index % 2 === 0 ? -4 : 4),
|
|
y: (index) => -2 - index * 0.9,
|
|
autoAlpha: (index) => 1 - index * 0.015,
|
|
ease: "none",
|
|
stagger: 0.03,
|
|
scrollTrigger: {
|
|
trigger: heroConsole,
|
|
start: "top 76%",
|
|
end: "bottom 18%",
|
|
scrub: true,
|
|
},
|
|
});
|
|
|
|
gsap.to(consoleTiles, {
|
|
y: -12,
|
|
autoAlpha: 0.9,
|
|
ease: "none",
|
|
stagger: { each: 0.035, from: "end" },
|
|
scrollTrigger: {
|
|
trigger: heroConsole,
|
|
start: "top 72%",
|
|
end: "bottom 24%",
|
|
scrub: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
gsap.utils.toArray<HTMLElement>("[data-motion='section']").forEach((section) => {
|
|
gsap.from(section, {
|
|
autoAlpha: 0,
|
|
y: 42,
|
|
duration: 0.82,
|
|
ease: bossEase,
|
|
scrollTrigger: {
|
|
trigger: section,
|
|
start: "top 82%",
|
|
once: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
gsap.utils
|
|
.toArray<HTMLElement>("[data-motion='card']")
|
|
.filter((card) => !card.closest(".hero-section"))
|
|
.forEach((card, index) => {
|
|
gsap.from(card, {
|
|
autoAlpha: 0,
|
|
y: 28,
|
|
scale: 0.97,
|
|
duration: 0.62,
|
|
delay: (index % 4) * 0.04,
|
|
ease: bossEase,
|
|
scrollTrigger: {
|
|
trigger: card,
|
|
start: "top 88%",
|
|
once: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
const cleanupAnchors = setupSmoothAnchors(root);
|
|
const cleanupActiveNav = setupActiveNavigation(root);
|
|
const cleanupMagneticCards = window.matchMedia("(hover: hover) and (pointer: fine)").matches
|
|
? setupMagneticCards(root)
|
|
: () => undefined;
|
|
|
|
return () => {
|
|
cleanupMagneticCards();
|
|
cleanupActiveNav();
|
|
cleanupAnchors();
|
|
splitTitle?.revert();
|
|
};
|
|
});
|
|
|
|
return () => matchMedia.revert();
|
|
},
|
|
{ scope: scopeRef },
|
|
);
|
|
|
|
return (
|
|
<div ref={scopeRef} className="motion-root">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function setupSmoothAnchors(root: HTMLElement) {
|
|
const anchors = gsap.utils.toArray<HTMLAnchorElement>("a[href^='#']", root);
|
|
|
|
const cleanups = anchors.map((anchor) => {
|
|
const onClick = (event: MouseEvent) => {
|
|
const href = anchor.getAttribute("href");
|
|
const targetId = href?.slice(1);
|
|
const target = targetId ? document.getElementById(targetId) : null;
|
|
if (!href || !target) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
window.history.pushState(null, "", href);
|
|
gsap.to(window, {
|
|
duration: 0.92,
|
|
ease: bossEase,
|
|
overwrite: "auto",
|
|
scrollTo: { y: target, offsetY: 22 },
|
|
});
|
|
};
|
|
|
|
anchor.addEventListener("click", onClick);
|
|
return () => anchor.removeEventListener("click", onClick);
|
|
});
|
|
|
|
return () => cleanups.forEach((cleanup) => cleanup());
|
|
}
|
|
|
|
function setupActiveNavigation(root: HTMLElement) {
|
|
const links = gsap.utils.toArray<HTMLAnchorElement>(".nav-links a[href^='#']", root);
|
|
const linkById = new Map(
|
|
links
|
|
.map((link) => [link.getAttribute("href")?.slice(1), link] as const)
|
|
.filter((entry): entry is readonly [string, HTMLAnchorElement] => Boolean(entry[0])),
|
|
);
|
|
|
|
const setActive = (id: string) => {
|
|
links.forEach((link) => link.classList.toggle("is-active", linkById.get(id) === link));
|
|
};
|
|
|
|
const triggers = gsap.utils
|
|
.toArray<HTMLElement>("section[id]", root)
|
|
.filter((section) => linkById.has(section.id))
|
|
.map((section) =>
|
|
ScrollTrigger.create({
|
|
trigger: section,
|
|
start: "top 42%",
|
|
end: "bottom 42%",
|
|
onEnter: () => setActive(section.id),
|
|
onEnterBack: () => setActive(section.id),
|
|
}),
|
|
);
|
|
|
|
return () => {
|
|
triggers.forEach((trigger) => trigger.kill());
|
|
links.forEach((link) => link.classList.remove("is-active"));
|
|
};
|
|
}
|
|
|
|
function setupMagneticCards(root: HTMLElement) {
|
|
const cards = gsap.utils.toArray<HTMLElement>("[data-motion='card']", root);
|
|
|
|
const cleanups = cards.map((card) => {
|
|
const onPointerMove = (event: PointerEvent) => {
|
|
const rect = card.getBoundingClientRect();
|
|
const xRatio = (event.clientX - rect.left) / rect.width - 0.5;
|
|
const yRatio = (event.clientY - rect.top) / rect.height - 0.5;
|
|
|
|
gsap.to(card, {
|
|
x: xRatio * 10,
|
|
y: yRatio * 10,
|
|
rotateX: yRatio * -3,
|
|
rotateY: xRatio * 4,
|
|
duration: 0.38,
|
|
ease: "power3.out",
|
|
overwrite: "auto",
|
|
});
|
|
};
|
|
|
|
const onPointerLeave = () => {
|
|
gsap.to(card, {
|
|
x: 0,
|
|
y: 0,
|
|
rotateX: 0,
|
|
rotateY: 0,
|
|
duration: 0.58,
|
|
ease: bossEase,
|
|
overwrite: "auto",
|
|
});
|
|
};
|
|
|
|
card.addEventListener("pointermove", onPointerMove);
|
|
card.addEventListener("pointerleave", onPointerLeave);
|
|
|
|
return () => {
|
|
card.removeEventListener("pointermove", onPointerMove);
|
|
card.removeEventListener("pointerleave", onPointerLeave);
|
|
};
|
|
});
|
|
|
|
return () => cleanups.forEach((cleanup) => cleanup());
|
|
}
|