ui: Motion-driven animation layer (motion.dev)
build-and-deploy / build (push) Failing after 14m48s

Unify scroll reveals, hero entrance, nav active-underline, button springs,
the scroll-progress bar and backdrop parallax under one Motion system
(src/scripts/anim.ts), bundled into a self-hosted /_astro module so CSP
script-src 'self' holds. Progress bar + parallax now scroll()-driven and
cross-browser (was Chromium-only animation-timeline). Fail-open, reduced-motion
aware, content fully visible with JS off.
This commit is contained in:
2026-06-28 23:23:58 +10:00
parent 5d439752de
commit f6d92e5c87
8 changed files with 259 additions and 2195 deletions
+126
View File
@@ -0,0 +1,126 @@
/* Motion (motion.dev) — the site's unified animation layer.
*
* Imported by an Astro-PROCESSED <script> in Layout.astro, so Vite bundles it
* into a self-hosted /_astro/[hash].js → stays within CSP `script-src 'self'`
* (no inline, no CDN). Motion sets styles via JS (WAAPI / element.style), which
* is allowed under `style-src` — so no `unsafe-inline` is introduced.
*
* Progressive enhancement: every animated element is VISIBLE with JS off. The
* pre-paint head script (site.js) adds `html.js`; only THEN does CSS hide the
* pre-reveal state (see global.css). Reduced-motion: we no-op and let the CSS
* reduced-motion rules show everything immediately.
*/
import { animate, inView, scroll, stagger } from "motion";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// easeOutExpo-ish — confident, decelerating; good for entrances/reveals.
const EASE = [0.16, 1, 0.3, 1] as const;
if (!reduce) {
// Fail open: if anything in the Motion layer throws, drop `html.js` so the CSS
// pre-reveal hide releases and ALL content shows. A broken animation must
// never leave the page blank.
try {
run();
} catch {
document.documentElement.classList.remove("js");
}
}
function run(): void {
/* 1. Hero — staggered fade-up on load. */
const heroEls = document.querySelectorAll<HTMLElement>(".hero__inner > *");
if (heroEls.length) {
animate(
heroEls,
{ opacity: [0, 1], y: [14, 0] },
{ duration: 0.6, delay: stagger(0.08), ease: EASE },
);
}
/* 2. Scroll reveals — section heads, skill cards (+ staggered chips), CTAs.
* inView fires as each [data-reveal] enters; re-fires on re-entry. */
inView(
"[data-reveal]",
(el) => {
animate(el, { opacity: [0, 1], y: [16, 0] }, { duration: 0.55, ease: EASE });
const chips = el.querySelectorAll<HTMLElement>(".chip");
if (chips.length) {
animate(
chips,
{ opacity: [0, 1], y: [8, 0] },
{ duration: 0.42, delay: stagger(0.04, { startDelay: 0.12 }), ease: EASE },
);
}
},
{ amount: 0.2 },
);
/* 3. Backdrop parallax — drive --bg-shift from scroll progress (replaces the
* hand-rolled rAF listener; Motion throttles via ScrollTimeline/rAF). */
const bg = document.querySelectorAll<HTMLElement>(".site-bg__img");
if (bg.length) {
scroll((progress: number) => {
const shift = ((0.5 - progress) * window.innerHeight * 0.7).toFixed(1);
bg.forEach((img) => img.style.setProperty("--bg-shift", `${shift}px`));
});
}
/* 4. Top scroll-progress bar — cross-browser (replaces the Chromium-only CSS
* animation-timeline). scroll() scrubs the animation by page progress. */
const bar = document.querySelector<HTMLElement>(".scroll-progress");
if (bar) scroll(animate(bar, { scaleX: [0, 1] }, { ease: "linear" }));
/* 5. Nav — animated active-section underline + tactile hover. */
initNav();
/* 6. Buttons — spring lift on hover, press on tap. */
initButtons();
}
function initNav(): void {
const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>(".nav__links a[data-nav-id]"),
);
if (!links.length) return;
const spring = { type: "spring", stiffness: 380, damping: 30 } as const;
let active: HTMLAnchorElement | null = null;
const under = (a: HTMLAnchorElement, on: boolean) => {
const ul = a.querySelector<HTMLElement>(".nav__ul");
if (ul) animate(ul, { scaleX: on ? 1 : 0 }, spring);
};
for (const a of links) {
a.addEventListener("pointerenter", () => under(a, true));
a.addEventListener("pointerleave", () => under(a, a === active));
}
// Light up the link whose section is in view (homepage anchors only).
for (const a of links) {
const id = a.dataset.navId;
const sec = id ? document.getElementById(id) : null;
if (!sec) continue;
inView(
sec,
() => {
if (active && active !== a) under(active, false);
active = a;
under(a, true);
},
{ amount: 0.5 },
);
}
}
function initButtons(): void {
const lift = { type: "spring", stiffness: 400, damping: 26 } as const;
const tap = { type: "spring", stiffness: 600, damping: 22 } as const;
for (const b of document.querySelectorAll<HTMLElement>(".btn")) {
b.addEventListener("pointerenter", () => animate(b, { y: -2, scale: 1 }, lift));
b.addEventListener("pointerleave", () => animate(b, { y: 0, scale: 1 }, lift));
b.addEventListener("pointerdown", () => animate(b, { scale: 0.95 }, tap));
b.addEventListener("pointerup", () => animate(b, { scale: 1, y: -2 }, tap));
}
}