Last active
August 5, 2025 19:50
-
-
Save bonrow/c894cf4ea43a1cbd95f6bed87d850248 to your computer and use it in GitHub Desktop.
A React ScrollSpy component with context, using IntersectionObserver to track and highlight active sections as the user scrolls. Supports smooth scrolling to sections, dynamic registration/unregistration, and customizable section components. Designed for single-page navigation and dynamic landing pages.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Slot } from "@radix-ui/react-slot"; | |
| import { mergeRefs } from "@repo/react-utils/merge-refs"; | |
| import { atom, Atom } from "nanostores"; | |
| import React from "react"; | |
| interface ScrollSpySection< | |
| TId extends string = string, | |
| E extends HTMLElement = HTMLElement, | |
| > { | |
| id: TId; | |
| ref: React.RefObject<E | null>; | |
| } | |
| export interface ScrollSpyContext<TId extends string = string> { | |
| $sections: Atom<readonly ScrollSpySection<TId>[]>; | |
| $activeId: Atom<TId | null>; | |
| to(id: TId, scroll?: boolean): void; | |
| register(section: ScrollSpySection<TId>): boolean; | |
| unregister(id: TId): boolean; | |
| } | |
| const ScrollSpyContext = React.createContext<ScrollSpyContext | null>(null); | |
| export function useScrollSpy< | |
| TId extends string = string, | |
| >(): ScrollSpyContext<TId> { | |
| const context = React.useContext(ScrollSpyContext); | |
| if (!context) throw new Error("missing ScrollSpyProvider"); | |
| return context as unknown as ScrollSpyContext<TId>; | |
| } | |
| export function ScrollSpy({ | |
| children, | |
| initialId = null, | |
| }: Readonly<{ | |
| children: React.ReactNode; | |
| initialId?: string | null; | |
| }>) { | |
| const $sections = React.useMemo(() => atom<ScrollSpySection[]>([]), []); | |
| const $activeId = React.useMemo( | |
| () => atom<string | null>(initialId), | |
| [initialId], | |
| ); | |
| const activate = React.useCallback( | |
| (id: string, scroll: boolean = true) => { | |
| const sections = $sections.get(); | |
| const section = sections.find((x) => x.id === id); | |
| if (!section) throw new Error(`Section with id "${id}" not found`); | |
| if (scroll) | |
| section.ref.current?.scrollIntoView({ | |
| behavior: "smooth", | |
| block: "start", | |
| }); | |
| const url = new URL(window.location.href); | |
| url.hash = section.id === initialId ? String() : section.id; | |
| window.history.replaceState({}, "", url.toString()); | |
| $activeId.set(id); | |
| }, | |
| [$activeId, $sections], | |
| ); | |
| const context = React.useMemo<ScrollSpyContext>( | |
| () => ({ | |
| $sections, | |
| $activeId, | |
| to: activate, | |
| register: (section: ScrollSpySection): boolean => { | |
| const list = $sections.get(); | |
| if (list.some((x) => x.id === section.id)) return false; | |
| $sections.set([...list, section]); | |
| return true; | |
| }, | |
| unregister: (id: string): boolean => { | |
| const list = $sections.get(); | |
| const sizeBefore = list.length; | |
| $sections.set(list.filter((x) => x.id !== id)); | |
| return $sections.get().length < sizeBefore; | |
| }, | |
| }), | |
| [$activeId, $sections], | |
| ); | |
| React.useEffect(() => { | |
| let observerList: IntersectionObserver[] = []; | |
| const unsubscribe = $sections.subscribe((sections) => { | |
| observerList.forEach((observer) => observer.disconnect()); | |
| observerList = []; | |
| for (const section of sections) { | |
| if (!section.ref.current) continue; | |
| const observer = new IntersectionObserver( | |
| ([entry]) => { | |
| if (entry?.isIntersecting) activate(section.id, false); | |
| }, | |
| { rootMargin: "-50% 0px -50% 0px" }, | |
| ); | |
| observer.observe(section.ref.current); | |
| observerList.push(observer); | |
| } | |
| return () => { | |
| unsubscribe(); | |
| observerList.forEach((observer) => observer.disconnect()); | |
| observerList = []; | |
| }; | |
| }); | |
| return; | |
| }, [$sections]); | |
| return ( | |
| <ScrollSpyContext.Provider value={context}> | |
| {children} | |
| </ScrollSpyContext.Provider> | |
| ); | |
| } | |
| export function ScrollSpySection({ | |
| id, | |
| ref, | |
| asChild, | |
| children, | |
| ...restProps | |
| }: React.ComponentProps<"section"> & { | |
| id: string; | |
| asChild?: boolean; | |
| }) { | |
| const _ref = React.useRef<HTMLElement>(null); | |
| const scrollSpy = useScrollSpy(); | |
| React.useEffect(() => { | |
| scrollSpy.register({ id, ref: _ref }); | |
| return () => { | |
| scrollSpy.unregister(id); | |
| }; | |
| }, [scrollSpy]); | |
| const Comp = asChild ? Slot : "section"; | |
| return ( | |
| <Comp ref={mergeRefs(_ref, ref)} id={id} {...restProps}> | |
| {children} | |
| </Comp> | |
| ); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example usage: