From dae9bbc91aecdd48d69f1a429dca43753826624f Mon Sep 17 00:00:00 2001 From: Jonathon Wright Date: Mon, 29 Jun 2026 19:46:46 +1000 Subject: [PATCH] nav: animate Projects/Blog underlines + coherent directional slide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Projects/Blog had no data-nav-id so the scroll-spy skipped them entirely; point them at the homepage #projects/#writing sections so all five animate - Drive the underline declaratively (re-assert all on each change) so none can be left stranded mid-spring - Slide follows scroll direction: activating underline grows from its trailing edge while the rest collapse toward the leading edge (down→right, up→left) --- src/components/Nav.astro | 8 +++-- src/scripts/anim.ts | 66 +++++++++++++++++++++++++--------------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/components/Nav.astro b/src/components/Nav.astro index 1030bfb..9ebe832 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -2,11 +2,15 @@ import ThemeToggle from "./ThemeToggle.astro"; import { site } from "../data/site"; +// `id` ties a nav link to a homepage section so the Motion layer animates its +// underline as that section scrolls through (scroll-spy). Projects/Blog navigate +// to their own pages on click, but on the homepage they track the #projects and +// #writing sections so every nav item gets the underline — in both directions. const links = [ { 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: "Projects", href: "/projects/", id: "projects" }, + { label: "Blog", href: "/blog/", id: "writing" }, { label: "Contact", href: "/#contact", id: "contact" }, ]; --- diff --git a/src/scripts/anim.ts b/src/scripts/anim.ts index 9493fa9..557ce5a 100644 --- a/src/scripts/anim.ts +++ b/src/scripts/anim.ts @@ -97,45 +97,61 @@ function initNav(): void { if (!links.length) return; const spring = { type: "spring", stiffness: 380, damping: 30 } as const; - let active: HTMLAnchorElement | null = null; + let activeIdx = -1; - const under = (a: HTMLAnchorElement, on: boolean) => { + // `fromRight` flips the grow/collapse origin so the underline animates in the + // direction of travel: scrolling down it grows from the left (moves right); + // scrolling back up it grows from the right (moves left). + const under = (a: HTMLAnchorElement, on: boolean, fromRight = false): void => { const ul = a.querySelector(".nav__ul"); - if (ul) animate(ul, { scaleX: on ? 1 : 0 }, spring); + if (!ul) return; + ul.style.transformOrigin = fromRight ? "100% 50%" : "0% 50%"; + 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 currently being read. Driven straight off - // scroll position (recomputed every frame) so it tracks BOTH directions — the - // old per-section inView(enter) fired only on the way DOWN and went dead on the - // way back up. Homepage anchor links only (Projects/Blog have no section here). + // Links that map to a section ON THIS PAGE. Projects/Blog point at the homepage + // #projects / #writing sections so every nav item gets the underline; on their + // own pages those sections don't exist and they simply drop out of the spy. const sectioned = links.filter( (a) => a.dataset.navId && document.getElementById(a.dataset.navId), ); + + for (const a of links) { + a.addEventListener("pointerenter", () => under(a, true)); + a.addEventListener("pointerleave", () => + under(a, sectioned.indexOf(a) === activeIdx), + ); + } + + // Light up the link whose section is currently being read, recomputed from + // scroll position so it tracks BOTH directions. Every change re-asserts ALL + // underlines (active → 1, the rest → 0) so none can be left stranded, and the + // grow/collapse origin follows the scroll direction. if (sectioned.length) { const setActive = (): void => { const vh = window.innerHeight; - const ref = vh * 0.45; // the section whose top has passed this reading line is "active" - let current: HTMLAnchorElement | null = null; - for (const a of sectioned) { + const ref = vh * 0.45; // a section whose top has passed this line is "active" + let idx = -1; + sectioned.forEach((a, i) => { const sec = document.getElementById(a.dataset.navId as string); - if (sec && sec.getBoundingClientRect().top <= ref) current = a; - } + if (sec && sec.getBoundingClientRect().top <= ref) idx = i; + }); // At the very bottom the last (often short) section may never push its top - // above the ref line — force it, so Contact lights up at the end instead of - // the highlight getting stuck on the previous section (Skills). + // above the line — force it so the final item lights up. if (window.scrollY + vh >= document.documentElement.scrollHeight - 4) { - current = sectioned[sectioned.length - 1]; - } - if (current !== active) { - if (active) under(active, false); - active = current; - if (current) under(current, true); + idx = sectioned.length - 1; } + if (idx === activeIdx) return; + const backward = idx < activeIdx; // scrolling up → travel left + activeIdx = idx; + // Coherent directional slide: the activating underline grows from its + // trailing edge while the others collapse toward the leading edge, so the + // whole motion follows the scroll direction (down → right, up → left) + // instead of the active/old pair diverging. + sectioned.forEach((a, i) => { + const on = i === idx; + under(a, on, on ? backward : !backward); + }); }; scroll(setActive); setActive();