import { Children, cloneElement, isValidElement, useEffect, useState } from "react" interface ScrollSpyProps { /** IDs of sections to track */ ids: string[] /** Class to apply to active link */ activeClass?: string /** Offset from the top for determining when a section is active */ offset?: number /** Callback function when active section changes */ onChange?: (activeId: string | null) => void /** Optional component props */ className?: string children?: React.ReactNode } function ScrollSpy({ ids, activeClass = "active", offset = 0, onChange, className, children, }: ScrollSpyProps) { const [activeId, setActiveId] = useState(null) // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { const handleScroll = () => { // Find all sections const sections = ids.map(id => document.getElementById(id)).filter(Boolean) // Find the section that is currently visible let currentSectionId: string | null = null for (const section of sections) { if (!section) continue const sectionTop = section.getBoundingClientRect().top // Section is considered active if its top is near the viewport top if (sectionTop <= offset + 50) { currentSectionId = section.id } else { break } } if (currentSectionId !== activeId) { setActiveId(currentSectionId) onChange?.(currentSectionId) } } // Initial check handleScroll() // Add scroll event listener window.addEventListener("scroll", handleScroll) // Cleanup return () => { window.removeEventListener("scroll", handleScroll) } }, [ids, activeClass, offset, onChange, activeId]) // Clone children and add active class to matching child const childrenWithProps = Children.map(children, child => { if (isValidElement(child)) { const href = (child.props as { href?: string }).href if (href?.startsWith("#")) { return cloneElement(child) } } return child }) return (
{childrenWithProps}
) } export { ScrollSpy }