Last active
July 17, 2026 02:36
-
-
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
| "use client"; | |
| import { Slot } from "@radix-ui/react-slot"; | |
| import { type Atom, atom } from "nanostores"; | |
| import React from "react"; | |
| import { mergeRefs } from "../lib/merge-refs"; | |
| interface RegisteredSection< | |
| 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 RegisteredSection<TId>[]>; | |
| $activeId: Atom<TId | null>; | |
| to(id: TId, scroll?: boolean): void; | |
| register(section: RegisteredSection<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<RegisteredSection[]>([]), []); | |
| const $activeId = React.useMemo( | |
| () => atom<string | null>(initialId), | |
| [initialId], | |
| ); | |
| const pendingScrollTargetRef = React.useRef<string | null>(null); | |
| 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) { | |
| // Lock active section updates to the explicitly requested target | |
| // while smooth scrolling is in progress. | |
| pendingScrollTargetRef.current = id; | |
| } else if (pendingScrollTargetRef.current === id) { | |
| pendingScrollTargetRef.current = null; | |
| } | |
| if (scroll) { | |
| section.ref.current?.scrollIntoView({ | |
| behavior: "smooth", | |
| block: "start", | |
| }); | |
| } | |
| const url = new URL(window.location.href); | |
| url.hash = section.id === initialId ? "" : section.id; | |
| window.history.replaceState({}, "", url.toString()); | |
| $activeId.set(id); | |
| }, | |
| [$activeId, $sections, initialId], | |
| ); | |
| const context = React.useMemo<ScrollSpyContext>( | |
| () => ({ | |
| $sections, | |
| $activeId, | |
| to: activate, | |
| register: (section: RegisteredSection): 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, activate], | |
| ); | |
| 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) { | |
| const pendingTarget = pendingScrollTargetRef.current; | |
| if (pendingTarget && section.id !== pendingTarget) { | |
| return; | |
| } | |
| 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 = []; | |
| }; | |
| }, [$sections, activate]); | |
| return ( | |
| <ScrollSpyContext.Provider value={context}> | |
| {children} | |
| </ScrollSpyContext.Provider> | |
| ); | |
| } | |
| export function ScrollSpySection({ | |
| id, | |
| ref, | |
| asChild, | |
| children, | |
| ...restProps | |
| }: React.ComponentProps<"section"> & { | |
| id: string; | |
| asChild?: boolean; | |
| }) { | |
| const innerRef = React.useRef<HTMLElement>(null); | |
| const scrollSpy = useScrollSpy(); | |
| React.useEffect(() => { | |
| scrollSpy.register({ id, ref: innerRef }); | |
| return () => { | |
| scrollSpy.unregister(id); | |
| }; | |
| }, [scrollSpy, id]); | |
| const Comp = asChild ? Slot : "section"; | |
| return ( | |
| <Comp ref={mergeRefs(innerRef, 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: