ScrollSpy
NavigationSection navigation that follows a bounded, independently scrollable guide.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { ScrollSpy } from "@/components/ui/scroll-spy";
export default function ScrollSpyExample() {
return (
<div className="w-full max-w-xl space-y-5">
<div>
<h3 className="font-semibold">Launch guide</h3>
<p className="mt-1 text-sm text-muted-foreground">
Scroll this guide or use the section links to move around.
</p>
</div>
<ScrollSpy
label="Launch guide"
height={300}
sections={[
{
id: "overview",
title: "Overview",
content: (
<div className="space-y-4">
<p>
Start with a small, well-defined release. Give your team a
clear destination and enough room to make thoughtful choices
along the way.
</p>
<p>
This guide keeps your current section in view as you explore
the launch process.
</p>
</div>
),
},
{
id: "design",
title: "Design",
content: (
<div className="space-y-4">
<p>
Choose a visual direction, define your components, and test
the details in context. Consistency comes from shared
decisions.
</p>
<p>
Review light mode, dark mode, narrow viewports, and keyboard
interactions before moving on.
</p>
</div>
),
},
{
id: "release",
title: "Release",
content: (
<div className="space-y-4">
<p>
Run your checks, review your changes, and share the result
with your team. Keep your first release focused and easy to
understand.
</p>
<p>
Document what you learned and leave a clear path for the next
iteration.
</p>
</div>
),
},
]}
/>
</div>
);
}Usage
Provide unique section ids, titles, and content. This component owns a scroll region; it tracks sections inside that region rather than the whole document. Section links scroll and focus the matching section without changing the page URL. The final section becomes active at the bottom. A bounded pane makes this suitable for embedded guides, inspectors, and documentation previews.
import { ScrollSpy } from "@/components/ui/scroll-spy";Shipped as editable source in src/components/ui/scroll-spy.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/scroll-spy.tsx
"use client";
import { useId, useRef, useState, type ReactNode } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ScrollSpySection {
id: string;
title: string;
content: ReactNode;
}
export interface ScrollSpyProps {
label: string;
sections: ScrollSpySection[];
height?: number;
className?: string;
}
export function ScrollSpy({
label,
sections,
height = 320,
className,
}: ScrollSpyProps) {
const prefix = useId();
const root = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<string | undefined>(sections[0]?.id);
const selected = sections.some((section) => section.id === active)
? active
: sections[0]?.id;
const update = () => {
const container = root.current;
if (!container) return;
const top = container.getBoundingClientRect().top;
let next: string | undefined = sections[0]?.id;
for (const element of container.querySelectorAll<HTMLElement>(
"[data-spy-section]",
)) {
if (element.getBoundingClientRect().top - top <= 32)
next = element.dataset.spySection;
}
if (
container.scrollTop + container.clientHeight >=
container.scrollHeight - 2
)
next = sections.at(-1)?.id;
setActive(next);
};
return (
<div className={cn("min-w-0 space-y-3", className)}>
<nav
aria-label={label}
className="flex flex-wrap gap-1 rounded-lg border bg-muted/30 p-1"
>
{sections.map((section) => (
<a
key={section.id}
href={`#${prefix}-${section.id}`}
aria-current={selected === section.id ? "location" : undefined}
onClick={(event) => {
event.preventDefault();
const element = document.getElementById(
`${prefix}-${section.id}`,
);
const container = root.current;
if (!element || !container) return;
container.scrollTo({
top:
container.scrollTop +
element.getBoundingClientRect().top -
container.getBoundingClientRect().top -
16,
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
element.focus({ preventScroll: true });
}}
className={cn(
"rounded-md px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected === section.id
? "bg-background font-medium shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{section.title}
</a>
))}
</nav>
<div
ref={root}
role="region"
aria-label={`${label} content`}
tabIndex={0}
onScroll={update}
style={{
height: Math.max(120, Number.isFinite(height) ? height : 320),
}}
className="overflow-y-auto rounded-xl border bg-card p-5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{sections.length ? (
sections.map((section) => (
<section
key={section.id}
id={`${prefix}-${section.id}`}
data-spy-section={section.id}
tabIndex={-1}
aria-labelledby={`${prefix}-${section.id}-title`}
className="min-h-64 space-y-4 pb-10 outline-none"
>
<h3
id={`${prefix}-${section.id}-title`}
className="text-base font-semibold"
>
{section.title}
</h3>
<div className="text-sm leading-7 text-muted-foreground">
{section.content}
</div>
</section>
))
) : (
<p className="text-sm text-muted-foreground">
No sections available.
</p>
)}
</div>
</div>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Accessible name for navigation and its content region. |
| sections | ScrollSpySection[]Default: required | Unique id, title, and ReactNode content in document order. |
| height | numberDefault: 320 | Pane height in pixels; clamped to at least 120. |
| className | stringDefault: — | Extend the component container with theme-aware utility classes. |