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
+69 -2077
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"@astrojs/sitemap": "^3.7.3",
"@tailwindcss/vite": "^4.3.1",
"astro": "^7.0.3",
"motion": "^12.42.0",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
+9 -54
View File
@@ -12,6 +12,11 @@
document.documentElement.dataset.theme = "dark";
}
// Mark JS as live BEFORE first paint. CSS only hides the pre-reveal animation
// state under `html.js`, so with JS off every element stays visible (the
// Motion layer in /_astro reveals them once it runs). Progressive enhancement.
document.documentElement.classList.add("js");
function onReady(fn) {
if (document.readyState !== "loading") fn();
else document.addEventListener("DOMContentLoaded", fn);
@@ -33,59 +38,9 @@
});
}
// --- Reveal on scroll (progressive enhancement, motion-aware) ----------
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var targets = document.querySelectorAll("[data-reveal]");
if (!reduce && "IntersectionObserver" in window) {
targets.forEach(function (el) {
el.classList.add("reveal");
});
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
});
},
{ rootMargin: "0px 0px -10% 0px", threshold: 0.1 },
);
targets.forEach(function (el) {
io.observe(el);
});
}
// --- Scroll-coupled background pan -------------------------------------
// The Bat-Computer backdrop pans vertically in lock-step with scroll —
// moving ONLY while you scroll, still at rest. Plain scroll listener so it
// works in every browser (unlike CSS animation-timeline: scroll(), which is
// Chromium-only). rAF-throttled; sets a CSS var the transform reads.
var bgImgs = document.querySelectorAll(".site-bg__img");
if (bgImgs.length) {
var ticking = false;
function updateBg() {
ticking = false;
var doc = document.documentElement;
var max = doc.scrollHeight - window.innerHeight;
var p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;
// +half a range at the top → -half at the bottom: scrolling down pans
// the camera down the board. Range kept within the image's overhang so
// an edge never shows (image is 215% tall → 57.5vh spare each side).
var shift = (0.5 - p) * window.innerHeight * 0.7;
for (var i = 0; i < bgImgs.length; i++) {
bgImgs[i].style.setProperty("--bg-shift", shift.toFixed(1) + "px");
}
}
function onScroll() {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(updateBg);
}
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
updateBg();
}
// Scroll reveals, hero entrance, backdrop parallax, scroll-progress bar,
// nav underline and button springs all live in the Motion layer
// (src/scripts/anim.ts → bundled /_astro/*.js). This file owns only the
// pre-paint theme so it can stay a tiny blocking <head> script.
});
})();
+5 -31
View File
@@ -114,39 +114,13 @@ import { cvAvailable } from "../lib/assets";
gap: var(--space-3);
}
/* Staggered entrance — CSS-only (runs with JS off), motion-aware. */
/* The staggered fade-up entrance is driven by the Motion layer
(src/scripts/anim.ts). What stays here is the one-shot electric sheen that
sweeps across the nameplate once on load — a pure background-position paint
effect (no transform/opacity), motion-aware. */
@media (prefers-reduced-motion: no-preference) {
.hero__eyebrow,
.hero__title,
.hero__positioning,
.hero__tags,
.hero__cta {
animation: heroIn 0.7s var(--ease) both;
}
/* Entrance + a one-shot electric sheen sweeping across the name. */
.hero__title {
animation:
heroIn 0.7s var(--ease) 0.06s both,
heroSheen 1.6s var(--ease) 0.7s both;
}
.hero__positioning {
animation-delay: 0.14s;
}
.hero__tags {
animation-delay: 0.22s;
}
.hero__cta {
animation-delay: 0.3s;
}
}
@keyframes heroIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
animation: heroSheen 1.6s var(--ease) 0.7s both;
}
}
/* The light sheen band (1st layer) sweeps left across the glyphs once, then
+26 -6
View File
@@ -3,11 +3,11 @@ import ThemeToggle from "./ThemeToggle.astro";
import { site } from "../data/site";
const links = [
{ label: "About", href: "/#about" },
{ label: "Skills", href: "/#skills" },
{ label: "Projects", href: "/projects/" },
{ label: "Blog", href: "/blog/" },
{ label: "Contact", href: "/#contact" },
{ label: "About", href: "/#about", id: "about" },
{ label: "Skills", href: "/#skills", id: "skills" },
{ label: "Projects", href: "/projects/", id: null },
{ label: "Blog", href: "/blog/", id: null },
{ label: "Contact", href: "/#contact", id: "contact" },
];
---
@@ -18,7 +18,11 @@ const links = [
</a>
<nav class="nav__links" aria-label="Primary">
{links.map((l) => <a href={l.href}>{l.label}</a>)}
{links.map((l) => (
<a href={l.href} data-nav-id={l.id}>
{l.label}<span class="nav__ul" aria-hidden="true"></span>
</a>
))}
</nav>
<div class="nav__actions">
@@ -80,12 +84,28 @@ const links = [
font-size: var(--step--1);
}
.nav__links a {
position: relative;
color: var(--text-dim);
}
.nav__links a:hover {
color: var(--text);
text-decoration: none;
}
/* Active-section / hover underline — scaleX scrubbed by the Motion layer
(spring). transform-origin left so it grows from the start of the label. */
.nav__ul {
position: absolute;
left: 0;
right: 0;
bottom: -0.45rem;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 8px var(--accent-glow);
transform: scaleX(0);
transform-origin: 0 50%;
pointer-events: none;
}
.nav__actions {
display: flex;
align-items: center;
+6
View File
@@ -70,5 +70,11 @@ const ogImage = new URL(site.ogImage, site.url).href;
<slot />
</main>
<Footer />
<!-- Motion (motion.dev) animation layer. NOT is:inline → Astro/Vite bundles
it to a self-hosted /_astro/[hash].js (has src=) → strict CSP holds. -->
<script>
import "../scripts/anim.ts";
</script>
</body>
</html>
+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));
}
}
+17 -27
View File
@@ -128,14 +128,15 @@ svg {
border: 1px solid var(--border-strong);
color: var(--text);
background: var(--surface);
transition: border-color 0.2s var(--ease), transform 0.2s var(--ease),
/* transform is owned by the Motion layer (spring hover/press) — kept out of
this transition so CSS and Motion don't fight over the same property. */
transition: border-color 0.2s var(--ease),
background 0.2s var(--ease), color 0.2s var(--ease);
text-decoration: none;
}
.btn:hover {
text-decoration: none;
border-color: var(--accent-line);
transform: translateY(-1px);
}
.btn--primary {
background: linear-gradient(100deg, var(--accent), var(--accent-2));
@@ -308,23 +309,21 @@ svg {
color: var(--shiki-light);
}
/* ---- Reveal-on-scroll (progressive enhancement) -------------------------- */
/* Default = visible (JS-off safe). The .reveal class is only added by JS when
IntersectionObserver is supported AND motion is allowed. */
.reveal {
/* ---- Pre-reveal state (progressive enhancement, Motion-driven) ----------- */
/* Default = visible (JS-off safe). Only once site.js sets `html.js` do we hide
the pre-reveal elements; the Motion layer (src/scripts/anim.ts) then fades
them up. Opacity alone gates the flash — Motion owns the y-transform. Under
reduced-motion the Motion layer no-ops, so we keep everything visible here. */
html.js [data-reveal],
html.js .hero__inner > *,
html.js [data-reveal] .chip {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
}
.reveal.is-visible {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
html.js [data-reveal],
html.js .hero__inner > *,
html.js [data-reveal] .chip {
opacity: 1;
transform: none;
transition: none;
}
}
@@ -332,7 +331,9 @@ svg {
Native scroll-driven animations (animation-timeline). Degrades to a gentle
continuous drift where unsupported; fully disabled under reduced-motion. */
/* A thin royal line at the very top that fills as the page scrolls. */
/* A thin royal line at the very top that fills as the page scrolls. Its scaleX
is scrubbed by scroll progress in the Motion layer (scroll() — cross-browser,
unlike the Chromium-only CSS animation-timeline this replaced). */
.scroll-progress {
position: fixed;
inset: 0 0 auto 0;
@@ -344,17 +345,6 @@ svg {
transform-origin: 0 50%;
pointer-events: none;
}
@supports (animation-timeline: scroll()) {
.scroll-progress {
animation: progressGrow linear both;
animation-timeline: scroll(root block);
}
}
@keyframes progressGrow {
to {
transform: scaleX(1);
}
}
/* ---- Cinematic backing: the Bat-Computer board, panned by scroll -----------
A fixed full-screen image (dark/light per theme) that pans S → E → SE → NE as