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:
Generated
+69
-2077
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"@astrojs/sitemap": "^3.7.3",
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"astro": "^7.0.3",
|
"astro": "^7.0.3",
|
||||||
|
"motion": "^12.42.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+9
-54
@@ -12,6 +12,11 @@
|
|||||||
document.documentElement.dataset.theme = "dark";
|
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) {
|
function onReady(fn) {
|
||||||
if (document.readyState !== "loading") fn();
|
if (document.readyState !== "loading") fn();
|
||||||
else document.addEventListener("DOMContentLoaded", fn);
|
else document.addEventListener("DOMContentLoaded", fn);
|
||||||
@@ -33,59 +38,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Reveal on scroll (progressive enhancement, motion-aware) ----------
|
// Scroll reveals, hero entrance, backdrop parallax, scroll-progress bar,
|
||||||
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
// nav underline and button springs all live in the Motion layer
|
||||||
var targets = document.querySelectorAll("[data-reveal]");
|
// (src/scripts/anim.ts → bundled /_astro/*.js). This file owns only the
|
||||||
if (!reduce && "IntersectionObserver" in window) {
|
// pre-paint theme so it can stay a tiny blocking <head> script.
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -114,39 +114,13 @@ import { cvAvailable } from "../lib/assets";
|
|||||||
gap: var(--space-3);
|
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) {
|
@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 {
|
.hero__title {
|
||||||
animation:
|
animation: heroSheen 1.6s var(--ease) 0.7s both;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* The light sheen band (1st layer) sweeps left across the glyphs once, then
|
/* The light sheen band (1st layer) sweeps left across the glyphs once, then
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import ThemeToggle from "./ThemeToggle.astro";
|
|||||||
import { site } from "../data/site";
|
import { site } from "../data/site";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ label: "About", href: "/#about" },
|
{ label: "About", href: "/#about", id: "about" },
|
||||||
{ label: "Skills", href: "/#skills" },
|
{ label: "Skills", href: "/#skills", id: "skills" },
|
||||||
{ label: "Projects", href: "/projects/" },
|
{ label: "Projects", href: "/projects/", id: null },
|
||||||
{ label: "Blog", href: "/blog/" },
|
{ label: "Blog", href: "/blog/", id: null },
|
||||||
{ label: "Contact", href: "/#contact" },
|
{ label: "Contact", href: "/#contact", id: "contact" },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,7 +18,11 @@ const links = [
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav__links" aria-label="Primary">
|
<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>
|
</nav>
|
||||||
|
|
||||||
<div class="nav__actions">
|
<div class="nav__actions">
|
||||||
@@ -80,12 +84,28 @@ const links = [
|
|||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
}
|
}
|
||||||
.nav__links a {
|
.nav__links a {
|
||||||
|
position: relative;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.nav__links a:hover {
|
.nav__links a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
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 {
|
.nav__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -70,5 +70,11 @@ const ogImage = new URL(site.ogImage, site.url).href;
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
@@ -128,14 +128,15 @@ svg {
|
|||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface);
|
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);
|
background 0.2s var(--ease), color 0.2s var(--ease);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-color: var(--accent-line);
|
border-color: var(--accent-line);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background: linear-gradient(100deg, var(--accent), var(--accent-2));
|
background: linear-gradient(100deg, var(--accent), var(--accent-2));
|
||||||
@@ -308,23 +309,21 @@ svg {
|
|||||||
color: var(--shiki-light);
|
color: var(--shiki-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Reveal-on-scroll (progressive enhancement) -------------------------- */
|
/* ---- Pre-reveal state (progressive enhancement, Motion-driven) ----------- */
|
||||||
/* Default = visible (JS-off safe). The .reveal class is only added by JS when
|
/* Default = visible (JS-off safe). Only once site.js sets `html.js` do we hide
|
||||||
IntersectionObserver is supported AND motion is allowed. */
|
the pre-reveal elements; the Motion layer (src/scripts/anim.ts) then fades
|
||||||
.reveal {
|
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;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reveal {
|
html.js [data-reveal],
|
||||||
|
html.js .hero__inner > *,
|
||||||
|
html.js [data-reveal] .chip {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: none;
|
|
||||||
transition: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +331,9 @@ svg {
|
|||||||
Native scroll-driven animations (animation-timeline). Degrades to a gentle
|
Native scroll-driven animations (animation-timeline). Degrades to a gentle
|
||||||
continuous drift where unsupported; fully disabled under reduced-motion. */
|
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 {
|
.scroll-progress {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
@@ -344,17 +345,6 @@ svg {
|
|||||||
transform-origin: 0 50%;
|
transform-origin: 0 50%;
|
||||||
pointer-events: none;
|
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 -----------
|
/* ---- 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
|
A fixed full-screen image (dark/light per theme) that pans S → E → SE → NE as
|
||||||
|
|||||||
Reference in New Issue
Block a user