diff --git a/web/src/components/QuickNav/QuickNav.styles.ts b/web/src/components/QuickNav/QuickNav.styles.ts index d44bf06..677efac 100644 --- a/web/src/components/QuickNav/QuickNav.styles.ts +++ b/web/src/components/QuickNav/QuickNav.styles.ts @@ -99,15 +99,18 @@ export const QuickNavArrow = styled(Icon)` transform var(--dur-fast) var(--ease-out); `; -export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>` +export const QuickNavLink = styled.a<{ $active: boolean; $open: boolean; $delay: number }>` display: grid; grid-template-columns: 2rem 1fr auto; min-height: 2.65rem; flex: 1; align-items: center; + padding: 0 0.25rem; border-bottom: 1px solid var(--outline); - color: var(--ink-secondary); + background-color: transparent; + color: ${({ $active }) => ($active ? "var(--ink-primary)" : "var(--ink-secondary)")}; font-size: 0.93rem; + font-weight: ${({ $active }) => ($active ? 500 : 400)}; letter-spacing: -0.02em; opacity: ${({ $open }) => ($open ? 1 : 0)}; transform: translateY(${({ $open }) => ($open ? 0 : "7px")}); @@ -117,9 +120,24 @@ export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>` opacity 280ms ease ${({ $delay }) => `${$delay}ms`}, transform 420ms var(--ease-out) ${({ $delay }) => `${$delay}ms`}; - &:hover, + ${QuickNavIndex}, + ${QuickNavArrow} { + color: ${({ $active }) => ($active ? "var(--accent)" : undefined)}; + } + + ${QuickNavArrow} { + transform: none; + } + + &:hover { + background-color: rgba(244, 238, 228, 0.035); + color: var(--ink-primary); + } + &:focus-visible { - background: linear-gradient(90deg, rgba(232, 133, 97, 0.08), transparent 75%); + outline: 1px solid rgba(232, 133, 97, 0.35); + outline-offset: -1px; + background-color: rgba(232, 133, 97, 0.07); color: var(--ink-primary); } @@ -132,7 +150,7 @@ export const QuickNavLink = styled.a<{ $open: boolean; $delay: number }>` &:hover ${QuickNavArrow}, &:focus-visible ${QuickNavArrow} { - transform: translateX(3px); + transform: translateX(2px); } @media (prefers-reduced-motion: reduce) { diff --git a/web/src/components/QuickNav/QuickNav.tsx b/web/src/components/QuickNav/QuickNav.tsx index f56296b..e5f9508 100644 --- a/web/src/components/QuickNav/QuickNav.tsx +++ b/web/src/components/QuickNav/QuickNav.tsx @@ -45,11 +45,13 @@ const easeOutExpo = (progress: number) => { export function QuickNav() { const [open, setOpen] = useState(false); + const [activeHref, setActiveHref] = useState(ITEMS[0].href); const prefersReducedMotion = usePrefersReducedMotion(); const rootRef = useRef(null); const toggleRef = useRef(null); const scrollFrameRef = useRef(null); const scrollDelayRef = useRef(null); + const activeIndex = ITEMS.findIndex((item) => item.href === activeHref); const cancelPendingScroll = () => { if (scrollFrameRef.current !== null) { @@ -100,8 +102,44 @@ export function QuickNav() { [], ); + useEffect(() => { + let frame: number | null = null; + + const updateActiveSection = () => { + frame = null; + const marker = window.innerHeight * 0.36; + let currentHref: string = ITEMS[0].href; + + for (const item of ITEMS) { + const section = document.querySelector(item.href); + if (!section || section.getBoundingClientRect().top > marker) break; + currentHref = item.href; + } + + setActiveHref((current) => (current === currentHref ? current : currentHref)); + }; + + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(updateActiveSection); + }; + + scheduleUpdate(); + window.addEventListener("scroll", scheduleUpdate, { passive: true }); + window.addEventListener("resize", scheduleUpdate); + window.addEventListener("hashchange", scheduleUpdate); + + return () => { + window.removeEventListener("scroll", scheduleUpdate); + window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("hashchange", scheduleUpdate); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, []); + const finishAt = (target: HTMLElement, href: string) => { window.history.pushState(null, "", href); + setActiveHref(href); const hadTabIndex = target.hasAttribute("tabindex"); if (!hadTabIndex) target.setAttribute("tabindex", "-1"); @@ -150,6 +188,7 @@ export function QuickNav() { const onItemClick = (event: ReactMouseEvent, href: string) => { event.preventDefault(); cancelPendingScroll(); + setActiveHref(href); setOpen(false); const delay = prefersReducedMotion ? 0 : 60; @@ -165,24 +204,33 @@ export function QuickNav() { Site index - {String(ITEMS.length).padStart(2, "0")} + + {String(activeIndex + 1).padStart(2, "0")} /{" "} + {String(ITEMS.length).padStart(2, "0")} + - {ITEMS.map((item, index) => ( - onItemClick(event, item.href)} - > - {String(index + 1).padStart(2, "0")} - {item.label} - - - ))} + {ITEMS.map((item, index) => { + const active = item.href === activeHref; + + return ( + onItemClick(event, item.href)} + > + {String(index + 1).padStart(2, "0")} + {item.label} + + + ); + })}