- Full-screen loader whose only content is the logo animating: the "B" draws in and fills, the base facets trace in, then a wave runs along them until the page is ready (pure CSS, plays before JS) - Loader gate holds the loader until GSAP + fonts and every 3D scene's first frame are ready, then waits 1s (min 1.4s total) before leaving; hard 8s limit, head-script fallback if JS never boots - Scroll reveals, SplitText and scene entry animations start only once the loader leaves, so above-the-fold entries play in view - Internal links fade the loader back in before navigating, so page changes are one seamless dark transition - No loader for no-JS or reduced-motion visitors Co-Authored-By: Claude Opus 5.5 <[email protected]>
287 lines
8.7 KiB
TypeScript
287 lines
8.7 KiB
TypeScript
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<Exclude<Variant, "text">, 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<HTMLElement>("[data-reveal-stagger]").forEach(
|
|
revealGroup,
|
|
);
|
|
|
|
root.querySelectorAll<HTMLElement>(
|
|
`[data-reveal]:not([${DONE_ATTR}])`,
|
|
).forEach((el) => {
|
|
el.setAttribute(DONE_ATTR, "");
|
|
revealOne(el, variantOf(el), delayOf(el));
|
|
});
|
|
|
|
root.querySelectorAll<HTMLElement>(
|
|
`[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());
|
|
}
|