Skip to content
WANCRAFT

D.46Navigation & Menus

Scrollspy TOC

A table of contents that tracks the section you are reading with a sliding indicator and per-section progress.

NewReactMotionTailwindintermediate

Overview

Scrollspy TOC watches the sections of a page (or of any scrollable box passed as a container ref) with an IntersectionObserver and marks the entry being read with aria-current="location". A single shared-layout indicator, either a line on the rail or a pill behind the label, slides and resizes to the active entry on a spring. Each entry can fill its own rail segment as its section is read; that fill is written straight to a CSS variable inside a requestAnimationFrame, so scrolling never re-renders the list. Clicking an entry smooth-scrolls to the section (instantly with reduced motion), keeps the indicator steady until the scroll ends and moves focus to the section.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/scrollspy-toc.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/scrollspy-toc.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/scrollspy-toc.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { ScrollspyToc, type ScrollspyTocItem } from "@/components/ui/scrollspy-toc";

export function ScrollspyTocExample() {
  const items: ScrollspyTocItem[] = [
    { id: "introduction", label: "Introduction" },
    { id: "principles", label: "Principles" },
    { id: "timing", label: "Timing", level: 3 },
    { id: "accessibility", label: "Accessibility" },
  ];

  return (
    <aside className="sticky top-24 w-56">
      <ScrollspyToc items={items} />
    </aside>
  );
}

03Props

PropTypeDefaultDescription
itemsScrollspyTocItem[]—{ id: string; label: string; level?: number }. ids refer to section elements.
containerRefObject<HTMLElement | null>—Scrollable element containing the sections. Defaults to the window.
offsetnumber24Distance from the top of the scroll area where a section becomes active, in px.
indicator"line" | "pill""line"A line on the rail, or a pill behind the label.
showProgressbooleantrueFill each entry's rail segment as its section is read.
smoothbooleantrueSmooth-scroll on click (instant with reduced motion).
titlestring"On this page"Visible heading above the list; empty string hides it.
labelstring"Table of contents"Accessible name of the nav landmark.
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 380, damping: 34, mass: 0.8 }Spring for the indicator.
accentstring"#FF7448"Indicator, progress and focus ring colour.
onActiveChange(id: string) => void—Called when the active section changes.
...propsHTMLAttributes<HTMLElement>—Native attributes are forwarded to the <nav>.

04Features

  • 01IntersectionObserver activation band below a configurable offset, with an end-of-scroll fallback
  • 02Shared-layout indicator (line or pill) that slides and resizes between entries
  • 03Per-section reading progress written to a CSS variable in requestAnimationFrame: no re-renders while scrolling
  • 04Works with the window or any scrollable container via a ref
  • 05Nested levels indent automatically relative to the shallowest level
  • 06Click-scroll keeps the clicked entry active until scrolling ends
motion · layoutmotion · springmotion · scroll-linkedinput · scrollinput · clickinput · keyboard

05Accessibility

  • Rendered as a <nav> landmark with an accessible name and an ordered list of real links.
  • The active entry carries aria-current="location".
  • Links keep their #id href, so modified clicks and no-JS behaviour still work.
  • After a click, focus moves to the target section (made focusable with tabindex=-1) without a second scroll.
  • With prefers-reduced-motion, clicks jump instantly and the indicator moves without animation.

06Limitations

  • Nested levels are shown as indentation within a single flat list rather than nested lists.