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",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"astro": "^7.0.3",
|
||||
"motion": "^12.42.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+9
-54
@@ -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.
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user