nav: animate Projects/Blog underlines + coherent directional slide
build-and-deploy / build (push) Failing after 12m11s
build-and-deploy / build (push) Failing after 12m11s
- 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)
This commit is contained in:
@@ -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" },
|
||||
];
|
||||
---
|
||||
|
||||
+41
-25
@@ -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<HTMLElement>(".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();
|
||||
|
||||
Reference in New Issue
Block a user