Files
personal-site-bunext/src/components/motion/motion.ts
T
tbenandClaude Opus 5.5 c3514d2607 Add logo loading screen gated on animation readiness
- 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]>
2026-11-05 07:17:54 +01:00

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());
}