Skip to main content

Overview

useScrollSpy builds heading metadata from the DOM and tracks which heading is closest to a viewport offset. Useful for table-of-contents UIs that need an active section indicator while scrolling.

Installation

npm i @kuzenbo/hooks

Import

import { useScrollSpy } from "@kuzenbo/hooks";

Usage

Basic Table of Contents

import { useScrollSpy } from "@kuzenbo/hooks";

export function TableOfContents() {
  const { data, active } = useScrollSpy();

  return (
    <nav className="sticky top-4 p-4 border rounded-lg">
      <h3 className="font-semibold mb-2">Table of Contents</h3>
      <ul className="space-y-1">
        {data.map((heading, index) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              className={`block py-1 text-sm ${
                active === index
                  ? "text-primary font-medium"
                  : "text-muted-foreground hover:text-foreground"
              }`}
              style={{ paddingLeft: `${(heading.depth - 1) * 0.75}rem` }}
            >
              {heading.value}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Custom Selector

import { useScrollSpy } from "@kuzenbo/hooks";

export function CustomSelectorScrollSpy() {
  const { data, active, initialized } = useScrollSpy({
    selector: "h2, h3",
    offset: 100,
  });

  if (!initialized) {
    return <div>Loading...</div>;
  }

  return (
    <aside className="w-64">
      <ul className="space-y-2">
        {data.map((heading, index) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              className={`block ${
                active === index ? "text-primary" : "text-foreground"
              }`}
            >
              {heading.value}
            </a>
          </li>
        ))}
      </ul>
    </aside>
  );
}

With Custom Scroll Container

import { useScrollSpy } from "@kuzenbo/hooks";
import { useRef } from "react";

export function ScrollContainerSpy() {
  const scrollHostRef = useRef<HTMLDivElement>(null);
  const { data, active } = useScrollSpy({
    scrollHost: scrollHostRef.current,
    offset: 50,
  });

  return (
    <div className="flex gap-4">
      <nav className="w-48">
        <ul className="space-y-1">
          {data.map((heading, index) => (
            <li key={heading.id}>
              <a
                href={`#${heading.id}`}
                className={active === index ? "text-primary font-medium" : "text-muted-foreground"}
              >
                {heading.value}
              </a>
            </li>
          ))}
        </ul>
      </nav>

      <div
        ref={scrollHostRef}
        className="flex-1 h-96 overflow-auto border rounded-lg p-4"
      >
        {/* Your content with h1-h6 elements */}
      </div>
    </div>
  );
}

Custom Depth and Value Extraction

import { useScrollSpy } from "@kuzenbo/hooks";

export function CustomExtractionScrollSpy() {
  const { data, active } = useScrollSpy({
    selector: "[data-heading]",
    getDepth: (element) => Number(element.getAttribute("data-level") || 1),
    getValue: (element) => element.getAttribute("data-label") || element.textContent || "",
  });

  return (
    <nav>
      <ul>
        {data.map((heading, index) => (
          <li
            key={heading.id}
            className={active === index ? "font-bold" : ""}
          >
            <a href={`#${heading.id}`}>{heading.value}</a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Reinitialize on Content Change

import { useScrollSpy } from "@kuzenbo/hooks";
import { useEffect } from "react";

export function DynamicContentScrollSpy({ content }: { content: string }) {
  const { data, active, reinitialize } = useScrollSpy();

  useEffect(() => {
    // Reinitialize when content changes
    reinitialize();
  }, [content, reinitialize]);

  return (
    <nav>
      <ul>
        {data.map((heading, index) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              className={active === index ? "text-primary" : "text-foreground"}
            >
              {heading.value}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

API Reference

function useScrollSpy(
  options?: UseScrollSpyOptions
): UseScrollSpyReturnValue
options
UseScrollSpyOptions
Scroll spy configuration
options.selector
string
default:"h1, h2, h3, h4, h5, h6"
CSS selector used to find heading elements
options.getDepth
(element: HTMLElement) => number
Function that maps each heading element to its depth level. Defaults to reading tag name (h1=1, h2=2, etc.)
options.getValue
(element: HTMLElement) => string
Function that maps each heading element to the displayed label. Defaults to textContent
options.offset
number
default:0
Vertical offset used when calculating the active heading
options.scrollHost
HTMLElement
Scroll container to listen on; defaults to window
active
number
Index of the active heading in the data array (-1 if none)
data
UseScrollSpyHeadingData[]
Array of heading metadata objects
depth
number
Heading depth (1-6)
value
string
Heading text content
id
string
Heading ID (auto-generated if missing)
getNode
() => HTMLElement
Function to get the heading DOM node
initialized
boolean
true if headings have been retrieved from the DOM
reinitialize
() => void
Function to update headings after DOM changes

Type Definitions

interface UseScrollSpyHeadingData {
  depth: number;
  value: string;
  id: string;
  getNode: () => HTMLElement;
}

interface UseScrollSpyOptions {
  selector?: string;
  getDepth?: (element: HTMLElement) => number;
  getValue?: (element: HTMLElement) => string;
  scrollHost?: HTMLElement;
  offset?: number;
}

interface UseScrollSpyReturnValue {
  active: number;
  data: UseScrollSpyHeadingData[];
  initialized: boolean;
  reinitialize: () => void;
}

Caveats

  • Automatically generates IDs for headings that don’t have one
  • Uses CSS.escape for safe ID querying when available
  • Active heading is determined by closest distance to offset
  • Initializes on mount and listens to scroll events
  • Call reinitialize() if DOM structure changes after mount

SSR and RSC Notes

  • Use this hook in Client Components only
  • Do not call it from React Server Components
  • Requires DOM access to query headings

Build docs developers (and LLMs) love