"use client"; import { useEffect, useId, useState, type MouseEvent, type RefObject } from "react"; import { motion, useReducedMotion, useScroll, useSpring } from "motion/react"; export type ScrollNavItem = { label: string; href: `#${string}` }; type ScrollNavProps = { /** One link per section. href = "#" + the section's id. */ items: ScrollNavItem[]; /** Scrolling box that holds the sections. Leave out to use the page. */ container?: RefObject; /** Pixels from the top where a section counts as "reached" (e.g. a sticky header's height). */ offset?: number; /** Called on click, after the smooth scroll starts. */ onSelect?: (item: ScrollNavItem, e: MouseEvent) => void; orientation?: "vertical" | "horizontal"; /** Colour of the progress rail and active marker. */ accent?: string; /** Name read out by screen readers. */ label?: string; /** Text size and colour. */ className?: string; }; /* ScrollNav: a section list that follows your reading. The rail fills with scroll progress and the marker glides to the section you are in. */ export function ScrollNav({ items, container, offset = 80, onSelect, orientation = "vertical", accent = "#a78bfa", label = "On this page", className = "text-sm text-white/55", }: ScrollNavProps) { const reduce = useReducedMotion(); const uid = useId(); const [current, setCurrent] = useState(items[0]?.href); const { scrollYProgress } = useScroll(container ? { container } : undefined); const fill = useSpring(scrollYProgress, { stiffness: 200, damping: 30, restDelta: 0.001 }); const vertical = orientation === "vertical"; // The active section is the last one whose top has passed the offset line. useEffect(() => { const box = container?.current; const target: HTMLElement | Window = box ?? window; const find = (href: string) => (box ?? document).querySelector(href); const update = () => { const top = (box ? box.getBoundingClientRect().top : 0) + offset + 1; let next = items[0]?.href; for (const item of items) { const el = find(item.href); if (el && el.getBoundingClientRect().top <= top) next = item.href; } // At the very bottom, the last section wins even if it is short. const atEnd = box ? box.scrollTop + box.clientHeight >= box.scrollHeight - 2 : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2; if (atEnd) next = items[items.length - 1]?.href; setCurrent(next); }; update(); target.addEventListener("scroll", update, { passive: true }); window.addEventListener("resize", update); return () => { target.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; }, [items, container, offset]); const go = (item: ScrollNavItem, e: MouseEvent) => { const box = container?.current; const el = (box ?? document).querySelector(item.href); if (!el) return; e.preventDefault(); const behavior = reduce ? "auto" : "smooth"; if (box) { const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset; box.scrollTo({ top, behavior }); } else { window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior }); } setCurrent(item.href); onSelect?.(item, e); }; return ( ); }