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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user