platform-codebase/@packages/@hooks/react-hooks/src/use-scroll-spy.ts
Lilith dcae150ea6 chore: snapshot before monorepo consolidation
Capture current working state before converting platform-codebase
into a submodule of the lilith-platform monorepo.
2026-01-29 07:04:30 -08:00

111 lines
3 KiB
TypeScript

/**
* 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<string>())
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
}