Aura Design System

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-spy

Manual

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 };