Skip to content

ScrollSpy

SM · 950B gzip budget

Tracks visible section and highlights corresponding nav link.

Category
Navigation
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/scroll-spy.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { useCallback, useEffect, useRef, useState } from "react";

export interface ScrollSpyItem {
  id: string;
  label: string;
}

export interface ScrollSpyProps {
  items: ScrollSpyItem[];
  children: React.ReactNode;
  navClassName?: string;
  activeClassName?: string;
  activeStyle?: React.CSSProperties;
  offset?: number;
}

export function ScrollSpy({
  items,
  children,
  navClassName = "",
  activeClassName = "",
  activeStyle,
  offset = 100,
}: ScrollSpyProps) {
  const [activeId, setActiveId] = useState<string>("");
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    const headings = items
      .map((item) => document.getElementById(item.id))
      .filter(Boolean) as HTMLElement[];

    if (headings.length === 0) return;

    observerRef.current = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);

        if (visible.length > 0) {
          setActiveId(visible[0].target.id);
        }
      },
      {
        rootMargin: `-${offset}px 0px -60% 0px`,
        threshold: 0,
      }
    );

    headings.forEach((heading) => observerRef.current!.observe(heading));

    return () => observerRef.current?.disconnect();
  }, [items, offset]);

  const scrollTo = useCallback(
    (id: string) => {
      const el = document.getElementById(id);
      if (el) {
        el.scrollIntoView({ behavior: "smooth", block: "start" });
      }
    },
    []
  );

  return (
    <nav aria-label="Table of contents" className={navClassName}>
      <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
        {items.map((item) => (
          <li key={item.id}>
            <a
              href={`#${item.id}`}
              onClick={(e) => {
                e.preventDefault();
                scrollTo(item.id);
              }}
              className={activeId === item.id ? activeClassName : ""}
              style={{
                display: "block",
                paddingBlock: "0.5rem",
                paddingInline: "1rem",
                color: activeId === item.id
                  ? "var(--color-primary, #3b82f6)"
                  : "inherit",
                textDecoration: "none",
                fontWeight: activeId === item.id ? 600 : 400,
                borderInlineStart: activeId === item.id
                  ? "2px solid var(--color-primary, #3b82f6)"
                  : "2px solid transparent",
                transition: "all 0.2s ease",
                ...activeStyle,
              }}
              aria-current={activeId === item.id ? "location" : undefined}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
      {children}
    </nav>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation