Scroll spy nav
FreeAn outline that follows your reading: a marker glides to the section in view, and choosing a link scrolls there and moves focus into it.
pnpm dlx shadcn@latest add https://caliperui.site/r/scroll-spy-nav.jsonOverview
We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.
Requests go to privacy@halden.example and are answered within a week.
Data we keep
We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.
Requests go to privacy@halden.example and are answered within a week.
Retention
We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.
Requests go to privacy@halden.example and are answered within a week.
Sharing
We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.
Requests go to privacy@halden.example and are answered within a week.
Your rights
We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.
Requests go to privacy@halden.example and are answered within a week.
When to use #
Use it for
- Docs, policies and long settings pages.
- Any page with more than four sections.
Skip it when
- Short pages; the outline is noise.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/scroll-spy-nav.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above.
import { useRef } from "react" import { ScrollSpyNav } from "@/components/ui/scroll-spy-nav" export function ScrollSpyNavDemo() { const scroller = useRef<HTMLDivElement>(null); const sections = [ { id: "spy-overview", label: "Overview" }, { id: "spy-data", label: "Data we keep" }, { id: "spy-retention", label: "Retention", level: 2 as const }, { id: "spy-sharing", label: "Sharing" }, { id: "spy-rights", label: "Your rights" }, ]; return ( <div className="grid w-full max-w-2xl grid-cols-[minmax(0,1fr)_160px] gap-6 max-sm:grid-cols-1"> <div ref={scroller} className="h-[360px] overflow-y-auto rounded-[14px] p-5 shadow-[inset_0_0_0_1px_var(--border)]" tabIndex={0} role="region" aria-label="Privacy policy"> {sections.map((section) => ( <section key={section.id} id={section.id} className="mb-8 scroll-mt-4 outline-none"> <h3 className="mb-2 text-sm font-semibold">{section.label}</h3> <p className="text-sm leading-6 text-muted-foreground">We keep only what the product needs to work: your email, your team and what you create. We never sell it, and we delete it within 30 days of you asking. Backups roll off after 35 days.</p> <p className="mt-2 text-sm leading-6 text-muted-foreground">Requests go to privacy@halden.example and are answered within a week.</p> </section> ))} <div className="h-40" aria-hidden="true" /> </div> <ScrollSpyNav items={sections} scrollRoot={scroller} offset={24} className="max-sm:hidden" /> </div> ); }
API reference #
Accessibility #
It is built on native elements and keeps aria-label, aria-hidden and aria-current in step with state.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.