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
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>
);
}
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
Scroll spy configurationoptions.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
Vertical offset used when calculating the active heading
Scroll container to listen on; defaults to window
Index of the active heading in the data array (-1 if none)
data
UseScrollSpyHeadingData[]
Array of heading metadata objectsHeading ID (auto-generated if missing)
Function to get the heading DOM node
true if headings have been retrieved from the DOM
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