import { gsap } from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; import { hideLoader, onLoaderDone, showLoader } from "../loader/gate"; /** * # Site-wide motion system * * Declarative, attribute-driven animations powered by GSAP. Markup opts in: * * - `data-reveal="up|down|fade|left|right|scale|blur|text|line"` * Animate the element in when it scrolls into view (default `up`). * `text` splits the element into masked lines/words that rise in. * `line` draws the element in horizontally (dividers, rules). * - `data-reveal-delay="0.2"` extra delay in seconds. * - `data-reveal-stagger` reveal each direct child in sequence * (optional value = variant for children). * - `data-parallax="0.3"` scrubbed drift + fade as the element scrolls * out of view (value = strength). * * Elements are pre-hidden by CSS only once `html.motion` is set by the inline * head script, so no-JS / reduced-motion visitors always see content. * Nothing is animated until the page loader is dismissed (../loader/gate.ts), * so above-the-fold entries play in view instead of behind the overlay. */ type Variant = | "up" | "down" | "fade" | "left" | "right" | "scale" | "blur" | "text" | "line"; const FROM: Record, gsap.TweenVars> = { up: { y: 28 }, down: { y: -20 }, fade: {}, left: { x: -36 }, right: { x: 36 }, scale: { scale: 0.94 }, blur: { filter: "blur(12px)", y: 12 }, line: { scaleX: 0, transformOrigin: "left center" }, }; const DONE_ATTR = "data-reveal-done"; const EASE = "power3.out"; declare global { interface Window { __MOTION_READY?: boolean; } } let registered = false; let observer: MutationObserver | undefined; let refreshTimer: number | undefined; let refCount = 0; const cleanups: (() => void)[] = []; function prefersReducedMotion() { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function parseVariant(v: string | null, fallback: Variant = "up"): Variant { return v && (v in FROM || v === "text") ? (v as Variant) : fallback; } function variantOf(el: Element, fallback: Variant = "up"): Variant { return parseVariant(el.getAttribute("data-reveal"), fallback); } function delayOf(el: Element) { return parseFloat(el.getAttribute("data-reveal-delay") || "0") || 0; } function trigger(el: Element) { return { trigger: el, start: "top 90%", once: true, } satisfies ScrollTrigger.Vars; } function revealText(el: HTMLElement, delay: number) { const split = SplitText.create(el, { type: "lines,words", mask: "lines", linesClass: "motion-line", autoSplit: true, onSplit(self) { gsap.set(el, { autoAlpha: 1 }); return gsap.from(self.words, { yPercent: 110, duration: 1.1, ease: "expo.out", stagger: 0.045, delay, scrollTrigger: trigger(el), }); }, }); cleanups.push(() => split.revert()); } function revealOne(el: HTMLElement, variant: Variant, delay: number) { if (variant === "text") return revealText(el, delay); gsap.fromTo( el, { autoAlpha: 0, ...FROM[variant] }, { autoAlpha: 1, x: 0, y: 0, scale: variant === "line" ? undefined : 1, scaleX: variant === "line" ? 1 : undefined, filter: variant === "blur" ? "blur(0px)" : undefined, duration: variant === "line" ? 1.4 : 1, ease: variant === "line" ? "expo.inOut" : EASE, delay, clearProps: "transform,filter", scrollTrigger: trigger(el), }, ); } function revealGroup(parent: HTMLElement) { const childVariant = parseVariant( parent.getAttribute("data-reveal-stagger"), ); const children = Array.from(parent.children).filter( (c): c is HTMLElement => c instanceof HTMLElement && !c.hasAttribute(DONE_ATTR), ); if (!children.length) return; children.forEach((c) => c.setAttribute(DONE_ATTR, "")); gsap.set(parent, { autoAlpha: 1 }); const from = childVariant === "text" ? FROM.up : FROM[childVariant]; gsap.fromTo( children, { autoAlpha: 0, ...from }, { autoAlpha: 1, x: 0, y: 0, scale: 1, filter: childVariant === "blur" ? "blur(0px)" : undefined, duration: 0.9, ease: EASE, stagger: 0.08, delay: delayOf(parent), clearProps: "transform,filter", scrollTrigger: trigger(parent), }, ); } function parallax(el: HTMLElement) { const strength = parseFloat(el.getAttribute("data-parallax") || "0.3"); const section = el.closest("section") ?? el; gsap.to(el, { yPercent: -30 * strength, autoAlpha: 0, ease: "none", scrollTrigger: { trigger: section, start: "top top", end: "bottom top", scrub: true, }, }); } /** Process every not-yet-animated motion element inside `root`. */ function scan(root: ParentNode) { root.querySelectorAll("[data-reveal-stagger]").forEach( revealGroup, ); root.querySelectorAll( `[data-reveal]:not([${DONE_ATTR}])`, ).forEach((el) => { el.setAttribute(DONE_ATTR, ""); revealOne(el, variantOf(el), delayOf(el)); }); root.querySelectorAll( `[data-parallax]:not([${DONE_ATTR}])`, ).forEach((el) => { el.setAttribute(DONE_ATTR, ""); parallax(el); }); } /** Cover the page with the loader before following internal links. */ function setupPageExit() { let leaving = false; const onClick = (e: MouseEvent) => { if (e.defaultPrevented || e.button !== 0) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; const a = (e.target as Element | null)?.closest?.("a"); if (!a || a.target === "_blank" || a.hasAttribute("download")) return; const url = new URL(a.href, location.href); if (url.origin !== location.origin) return; if (url.pathname === location.pathname) return; // hash / same page if (/\.(pdf|png|jpe?g|webp|gif|svg|zip)$/i.test(url.pathname)) return; e.preventDefault(); if (leaving) return; leaving = true; showLoader().then(() => location.assign(url.href)); }; // restore if the page comes back from the back/forward cache const onShow = (e: PageTransitionEvent) => { if (!e.persisted) return; leaving = false; hideLoader(); }; document.addEventListener("click", onClick); window.addEventListener("pageshow", onShow); cleanups.push(() => { document.removeEventListener("click", onClick); window.removeEventListener("pageshow", onShow); }); } export function initMotion() { refCount++; if (refCount > 1) return destroyMotion; window.__MOTION_READY = true; if (prefersReducedMotion()) { document.documentElement.classList.remove("motion"); return destroyMotion; } if (!registered) { gsap.registerPlugin(ScrollTrigger, SplitText); registered = true; } setupPageExit(); cleanups.push(onLoaderDone(start)); return destroyMotion; } /** Runs once the loader leaves: animate what's on the page, watch for more. */ function start() { scan(document); // Pick up content React mounts later (tabs, lists, client fetches). observer = new MutationObserver((mutations) => { let found = false; for (const m of mutations) { m.addedNodes.forEach((n) => { if (n instanceof HTMLElement) found = true; }); } if (!found) return; scan(document); window.clearTimeout(refreshTimer); refreshTimer = window.setTimeout(() => ScrollTrigger.refresh(), 120); }); observer.observe(document.body, { childList: true, subtree: true }); // Layout shifts after fonts/images load would misplace trigger points. const onLoad = () => ScrollTrigger.refresh(); window.addEventListener("load", onLoad); document.fonts?.ready.then(onLoad).catch(() => {}); cleanups.push(() => window.removeEventListener("load", onLoad)); } function destroyMotion() { refCount = Math.max(0, refCount - 1); if (refCount > 0) return; observer?.disconnect(); observer = undefined; window.clearTimeout(refreshTimer); while (cleanups.length) cleanups.pop()!(); ScrollTrigger.getAll().forEach((t) => t.kill()); }