Scroll Spy TOC
A table of contents that tracks the section in view: a marker slides to the active heading and clicks scroll smoothly.
npx shadcn@latest add https://sundaeui.dev/r/scroll-spy.json'use client'
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import './scroll-spy.css'
export interface SpySection { id: string; title: string; level?: 1 | 2; body: ReactNode }
export interface ScrollSpyProps {
sections: SpySection[]
marker?: 'line' | 'dot'
fill?: 'dark' | 'light'
className?: string
}
/** TOC that follows the section in view. */
export function ScrollSpy({ sections, marker = 'line', fill = 'dark', className }: ScrollSpyProps) {
const [active, setActive] = useState(sections[0].id)
const body = useRef<HTMLDivElement>(null)
const links = useRef<Record<string, HTMLButtonElement | null>>({})
const lock = useRef(0)
const [mk, setMk] = useState({ y: 0, h: 0 })
useEffect(() => {
const root = body.current; if (!root) return
const io = new IntersectionObserver((entries) => {
if (Date.now() < lock.current) return
const vis = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
if (vis[0]) setActive(vis[0].target.id.replace(/^ssp-/, ''))
}, { root, rootMargin: '0px 0px -70% 0px' })
sections.forEach((s) => { const el = root.querySelector(`#ssp-${s.id}`); if (el) io.observe(el) })
return () => io.disconnect()
}, [sections])
useLayoutEffect(() => { const el = links.current[active]; if (el) setMk({ y: el.offsetTop, h: el.offsetHeight }) }, [active])
const go = (id: string) => {
const h = body.current?.querySelector<HTMLElement>(`#ssp-${id}`); if (!h) return
lock.current = Date.now() + 700; setActive(id)
body.current!.scrollTo({ top: h.offsetTop - 12 })
h.focus({ preventScroll: true })
}
return (
<div className={['sd-ssp', className].filter(Boolean).join(' ')} data-fill={fill} data-marker={marker}>
<nav className="sd-ssp__nav" aria-label="On this page">
<span className="sd-ssp__mk" aria-hidden style={{ transform: `translateY(${mk.y}px)`, height: mk.h }} />
{sections.map((s) => <button key={s.id} ref={(el) => { links.current[s.id] = el }} type="button" className="sd-ssp__a" data-l={s.level ?? 1} aria-current={active === s.id ? 'location' : undefined} onClick={() => go(s.id)}>{s.title}</button>)}
</nav>
<div ref={body} className="sd-ssp__body" tabIndex={0}>
{sections.map((s) => {
const Tag = s.level === 2 ? 'h5' : 'h4'
return <section key={s.id}><Tag id={`ssp-${s.id}`} tabIndex={-1}>{s.title}</Tag><p>{s.body}</p></section>
})}
</div>
</div>
)
}.sd-ssp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ssp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ssp button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ssp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ssp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ssp { width: min(100%, 360px); height: 400px; display: grid; grid-template-columns: 110px 1fr; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
@media (max-width: 420px) { .sd-ssp { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } .sd-ssp__nav { display: flex; overflow-x: auto; border-right: 0 !important; border-bottom: 1px solid var(--line); padding: 6px !important; } .sd-ssp__mk { display: none; } .sd-ssp__a { white-space: nowrap; padding-left: 10px !important; } .sd-ssp__a[aria-current] { background: var(--soft); } }
.sd-ssp__nav { position: relative; padding: 12px 8px; border-right: 1px solid var(--line); }
.sd-ssp__mk { position: absolute; left: 6px; width: 2px; border-radius: 2px; background: var(--fg); transition: transform 300ms cubic-bezier(0.3, 1.2, 0.4, 1), height 300ms; }
.sd-ssp[data-marker='dot'] .sd-ssp__mk { width: 6px; height: 6px !important; left: 4px; border-radius: 99px; margin-top: 9px; }
.sd-ssp__a { display: block; width: 100%; padding: 5px 6px 5px 12px; border-radius: 7px; text-align: left; font-size: 12px; color: var(--mute); line-height: 1.3; transition: color 160ms; }
.sd-ssp__a[data-l='2'] { padding-left: 22px; font-size: 11.5px; }
.sd-ssp__a:hover, .sd-ssp__a[aria-current] { color: var(--fg); }
.sd-ssp__a[aria-current] { font-weight: 600; }
.sd-ssp__body { overflow-y: auto; padding: 0 16px 120px; scroll-behavior: smooth; }
.sd-ssp__body h4 { margin: 18px 0 6px; font-size: 15px; outline: 0; scroll-margin-top: 12px; }
.sd-ssp__body h5 { margin: 14px 0 4px; font-size: 13.5px; outline: 0; scroll-margin-top: 12px; }
.sd-ssp__body p { margin: 0; font-size: 13px; line-height: 1.6; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-ssp *, .sd-ssp *::before, .sd-ssp *::after { animation: none !important; transition: none !important; } }Overview
An IntersectionObserver works out which heading is current as you read, and a marker slides beside the matching TOC entry. Clicking an entry scrolls there and briefly holds it active so the marker does not flicker.
Nested entries indent; on narrow cards the TOC becomes a horizontal chip row.
When to use
- Docs, long settings pages, legal text, reports.
When not to use
- Pages with fewer than three sections.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/scroll-spy.jsonnpx shadcn@latest add https://sundaeui.dev/r/scroll-spy.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/scroll-spy.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/scroll-spy.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy scroll-spy.tsx and scroll-spy.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ScrollSpy } from '@/components/sundae/scroll-spy'
<ScrollSpy />Props
| Prop | Type | Default |
|---|---|---|
sections*Sections to render and track. | { id: string; title: string; level?: 1 | 2; body: ReactNode }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A nav landmark; the current entry has aria-current="location".
- Activating an entry moves focus to the section heading.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ssp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Scroll Spy TOC free?
- Yes. Scroll Spy TOC is free for personal and commercial projects, with full source on this page.
- How do I install Scroll Spy TOC?
- Run npx shadcn@latest add https://sundaeui.dev/r/scroll-spy.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Scroll Spy TOC support keyboard and screen readers?
- A nav landmark; the current entry has aria-current="location". Activating an entry moves focus to the section heading.
- Does Scroll Spy TOC respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Pagination
Navigation · RefinedUnderline Tabs
Navigation · RefinedBreadcrumbs
Navigation · Refined- Pro
File Tree
Navigation · Refined - Pro
Collapsible Sidebar
Navigation · Refined Vertical Tabs
Navigation · Refined