/** * useScrollSpy — reusable scroll-spy hook with URL hash sync. * * Observes a list of section elements by ID and returns the currently * visible one. Optionally syncs the URL hash and enables smooth scrolling. * * Analytics-agnostic: use `onSectionVisible` to fire tracking events. * * @example * ```tsx * const activeId = useScrollSpy(['hero', 'findings', 'table'], { * onSectionVisible: (id) => trackView({ contentId: id, contentType: 'page' }), * }) * ``` */ import { useState, useEffect, useRef } from 'react' export interface ScrollSpyOptions { /** Root margin for the IntersectionObserver. Default: '-80px 0px -50% 0px' */ rootMargin?: string /** Enable smooth scrolling on the document element. Default: true */ enableSmoothScroll?: boolean /** Sync the active section ID to the URL hash via replaceState. Default: true */ updateUrlHash?: boolean /** Scroll to the URL hash fragment on mount. Default: true */ scrollToHashOnMount?: boolean /** Called once per section when it first enters the viewport */ onSectionVisible?: (id: string) => void } const DEFAULT_ROOT_MARGIN = '-80px 0px -50% 0px' export function useScrollSpy( sectionIds: string[], options: ScrollSpyOptions = {}, ): string { const { rootMargin = DEFAULT_ROOT_MARGIN, enableSmoothScroll = true, updateUrlHash = true, scrollToHashOnMount = true, onSectionVisible, } = options const [activeId, setActiveId] = useState('') const viewedRef = useRef(new Set()) const onVisibleRef = useRef(onSectionVisible) onVisibleRef.current = onSectionVisible // Enable smooth scrolling while mounted useEffect(() => { if (!enableSmoothScroll) return document.documentElement.style.scrollBehavior = 'smooth' return () => { document.documentElement.style.scrollBehavior = '' } }, [enableSmoothScroll]) // Scroll to hash on mount useEffect(() => { if (!scrollToHashOnMount) return const hash = window.location.hash.slice(1) if (!hash) return requestAnimationFrame(() => { const el = document.getElementById(hash) if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }) setActiveId(hash) } }) }, [scrollToHashOnMount]) // IntersectionObserver for scroll spy useEffect(() => { const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue const { id } = entry.target if (!id) continue setActiveId(id) if (updateUrlHash) { window.history.replaceState(null, '', `#${id}`) } if (!viewedRef.current.has(id)) { viewedRef.current.add(id) onVisibleRef.current?.(id) } } }, { rootMargin, threshold: 0 }, ) for (const id of sectionIds) { const el = document.getElementById(id) if (el) observer.observe(el) } return () => observer.disconnect() }, [sectionIds, rootMargin, updateUrlHash]) return activeId }