Replace vendored twui library with @moduletrace/twui package
- Import all twui layout/elements components and base.css from the @moduletrace/twui package instead of src/components/lib - Move the site's own motion and three modules out of the old lib folder into src/components/motion and src/components/three - Delete the vendored src/components/lib folder and its tsconfig excludes - Type BlogPostsListCard's post prop with the DB blog post type, fixing the type error in BlogPostsList Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
|
||||
/**
|
||||
* # 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.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fade the page out before following internal links. */
|
||||
function setupPageExit() {
|
||||
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();
|
||||
gsap.to("main", {
|
||||
autoAlpha: 0,
|
||||
y: -12,
|
||||
duration: 0.28,
|
||||
ease: "power2.in",
|
||||
onComplete: () => location.assign(url.href),
|
||||
});
|
||||
};
|
||||
// restore if the page comes back from the back/forward cache
|
||||
const onShow = (e: PageTransitionEvent) => {
|
||||
if (e.persisted) gsap.set("main", { clearProps: "all" });
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
scan(document);
|
||||
setupPageExit();
|
||||
|
||||
// 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));
|
||||
|
||||
return destroyMotion;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user