feat(frontend): add useGsapTransition hook for page animations with reduced motion support
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
import gsap from "gsap";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGsapTransition(tabKey: string) {
|
||||||
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ctxRef = useRef<gsap.Context | null>(null);
|
||||||
|
|
||||||
|
const animateIn = useCallback(() => {
|
||||||
|
// Kill any previously recorded animations to prevent conflicts
|
||||||
|
ctxRef.current?.kill();
|
||||||
|
|
||||||
|
const instant = prefersReducedMotion();
|
||||||
|
const scope = pageRef.current ?? undefined;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
|
||||||
|
// Page container: fade-in + slide-up (400ms ease-out)
|
||||||
|
if (pageRef.current) {
|
||||||
|
tl.fromTo(
|
||||||
|
pageRef.current,
|
||||||
|
{ opacity: 0, y: 20 },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: instant ? 0 : 0.4,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stagger children with data-stagger attribute
|
||||||
|
const staggerEls =
|
||||||
|
pageRef.current?.querySelectorAll<HTMLElement>("[data-stagger]");
|
||||||
|
if (staggerEls && staggerEls.length > 0) {
|
||||||
|
tl.fromTo(
|
||||||
|
staggerEls,
|
||||||
|
{ opacity: 0, y: 15 },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: instant ? 0 : 0.3,
|
||||||
|
stagger: instant ? 0 : 0.05,
|
||||||
|
ease: "power2.out",
|
||||||
|
},
|
||||||
|
"-=0.1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, scope);
|
||||||
|
|
||||||
|
ctxRef.current = ctx;
|
||||||
|
}, [tabKey]);
|
||||||
|
|
||||||
|
const animateOut = useCallback((): Promise<void> => {
|
||||||
|
// Kill any previously recorded animations to prevent conflicts
|
||||||
|
ctxRef.current?.kill();
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const instant = prefersReducedMotion();
|
||||||
|
const scope = pageRef.current ?? undefined;
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageRef.current) {
|
||||||
|
// Page container: fade-out + slide-down (300ms ease-in)
|
||||||
|
tl.to(pageRef.current, {
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
duration: instant ? 0 : 0.3,
|
||||||
|
ease: "power2.in",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No element to animate — resolve immediately
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, scope);
|
||||||
|
|
||||||
|
ctxRef.current = ctx;
|
||||||
|
});
|
||||||
|
}, [tabKey]);
|
||||||
|
|
||||||
|
// Cleanup all recorded animations on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
ctxRef.current?.kill();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pageRef, animateIn, animateOut };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gsapCardHover() {
|
||||||
|
return {
|
||||||
|
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
gsap.to(e.currentTarget, {
|
||||||
|
y: -4,
|
||||||
|
boxShadow: "0 8px 25px rgba(0,0,0,0.15)",
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "power2.out",
|
||||||
|
overwrite: "auto",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
gsap.to(e.currentTarget, {
|
||||||
|
y: 0,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "power2.out",
|
||||||
|
overwrite: "auto",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user