Skip to content

Instantly share code, notes, and snippets.

@bonrow
Last active August 5, 2025 19:50
Show Gist options
  • Select an option

  • Save bonrow/c894cf4ea43a1cbd95f6bed87d850248 to your computer and use it in GitHub Desktop.

Select an option

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.
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>
);
}
@bonrow
Copy link
Author

bonrow commented Aug 5, 2025

Example usage:

export function LandingNavbar() {
  const scrollSpy = useScrollSpy();
  const activeId = useStore(scrollSpy.$activeId);

  return (
    <nav>
      <ul className="flex gap-2 list-none">
        {items.map((item) => (
          <li
            key={item.label}
            onClick={(e) => {
              e.preventDefault();
              scrollSpy.to(item.sectionId);
            }}
          >
            {/* ... */}
          </li>
        ))}
      </ul>
    </nav>
  );
}

// page.tsx
// ...
<ScrollSpy>
  <LandingNavbar />
  <ScrollSpySection id="hero">
    {/* ... *}
  <ScrollSpySection>
  <ScrollSpySection id="about">
    {/* ... *}
  </ScrollSpySection>
  <ScrollSpySection id="pricing">
    {/* ... *}
  </ScrollSpySection>
</ScrollSpy>
// ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment