import React, { useState, useEffect } from 'react'; import throttle from 'lodash/fp/throttle'; export interface useScrollSpyParams { activeSectionDefault?: string; //The key of the section that should be active by default offsetPx?: number; sectionElementRefs: { [key: string]: React.RefObject<HTMLElement> }; //The key of each element should be uniq! throttleMs?: number; scrollingElement?: React.RefObject<HTMLElement>; } export const useScrollSpy = ({ activeSectionDefault = '', offsetPx = 0, scrollingElement, sectionElementRefs = {}, throttleMs = 100, }: useScrollSpyParams) => { const [activeSection, setActiveSection] = useState(activeSectionDefault); const scrollIntoView = (sectionKey: string) => { const sectionElement = sectionElementRefs[sectionKey].current; const scrollingContainer = scrollingElement?.current || window; if (sectionElement && scrollingContainer) { scrollingContainer.scrollTo({ top: sectionElement.getBoundingClientRect().top + offsetPx - 60, behavior: 'smooth', }); } }; const handle = throttle(throttleMs, () => { let currentSectionId = activeSection; Object.entries(sectionElementRefs).forEach(([sectionKey, ref]) => { const section = ref.current; if (!section || !(section instanceof Element)) return; // GetBoundingClientRect returns values relative to viewport if (section.getBoundingClientRect().top + offsetPx < 0) { currentSectionId = sectionKey; return; } return; }); setActiveSection(currentSectionId); }); useEffect(() => { const scrollable = scrollingElement?.current ?? window; scrollable.addEventListener('scroll', handle); // Run initially handle(); return () => { scrollable.removeEventListener('scroll', handle); }; }, [sectionElementRefs, offsetPx, scrollingElement, handle]); return { activeSection, scrollIntoView }; };