Scroll Spy
A list of links that marks the section in view.
Preview
Loading...
import { ScrollSpy } from "@/components/ui/ScrollSpy";
export function ScrollSpyDemo() {
return (
<div className="flex items-start gap-1">
<ScrollSpy
items={[
{ id: "spy-overview", label: "Overview" },
{ id: "spy-color", label: "Color" },
{ id: "spy-type", label: "Type" },
]}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<section id="spy-overview" className="h-16 rounded-sm bg-gray-3 p-1">
Overview
</section>
<section id="spy-color" className="h-16 rounded-sm bg-accent-3 p-1">
Color
</section>
<section id="spy-type" className="h-16 rounded-sm bg-gray-3 p-1">
Type
</section>
</div>
</div>
)Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/scroll-spyManual
Copy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the ScrollSpy component into your components/ui/ScrollSpy.tsx file.
"use client";
import * as React from "react";
import { cn } from "@/utils/class-names";
type ScrollSpyItem = { id: string; label: string };
function ScrollSpy({
className,
items,
label = "On this page",
}: {
className?: string;
items: ScrollSpyItem[];
label?: string;
}) {
const [active, setActive] = React.useState(items[0]?.id);
const signature = items.map((item) => item.id).join("|");
React.useEffect(() => {
const nodes = signature
.split("|")
.filter(Boolean)
.map((id) => document.getElementById(id))
.filter((node): node is HTMLElement => Boolean(node));
if (!nodes.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible?.target.id) setActive(visible.target.id);
},
{ rootMargin: "-20% 0px -60% 0px" },
);
nodes.forEach((node) => observer.observe(node));
return () => observer.disconnect();
}, [signature]);
return (
<nav aria-label={label} data-slot="scroll-spy" className={cn("flex flex-col gap-0.5", className)}>
{items.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
aria-current={active === item.id ? "true" : undefined}
className="rounded-sm px-1 py-0.5 text-gray-11 hover:bg-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8 aria-[current=true]:bg-accent-3 aria-[current=true]:text-accent-11"
>
{item.label}
</a>
))}
</nav>
);
}
export { ScrollSpy };
export type { ScrollSpyItem };