import { useCallback, useEffect, useMemo, useRef, useState } from "react" export default function useScrollSpy( sectionIds: string[], options: IntersectionObserverInit = {} ): { activeSectionId: string pauseScrollSpy: () => void } { const [activeSectionId, setActiveSectionId] = useState("") const observerIsInactive = useRef(false) const mergedOptions = useMemo( () => ({ root: null, // Make sure only to activate the section when it reaches the top of the viewport. // A negative value for rootMargin shrinks the root bounding box inward, // meaning elements will only be considered intersecting when they are further inside the viewport. rootMargin: "-15% 0% -85% 0%", threshold: 0, ...options, }), [options] ) const handleIntersection = useCallback( (entries: IntersectionObserverEntry[]) => { if (observerIsInactive.current) { return } const intersectingEntries: IntersectionObserverEntry[] = [] entries.forEach((e) => { if (e.isIntersecting) { intersectingEntries.push(e) } }) if (intersectingEntries.length) { setActiveSectionId(intersectingEntries[0].target.id) } }, [] ) useEffect(() => { const observer = new IntersectionObserver(handleIntersection, mergedOptions) const elements = sectionIds .map((id) => document.getElementById(id)) .filter((el): el is HTMLElement => !!el) elements.forEach((element) => { observer.observe(element) }) return () => elements.forEach((el) => el && observer.unobserve(el)) }, [sectionIds, mergedOptions, handleIntersection]) const pauseScrollSpy = () => { observerIsInactive.current = true setTimeout(() => { observerIsInactive.current = false }, 500) } return { activeSectionId, pauseScrollSpy } }