WebberUI

Scroll Spy Dots

Fixed side dots that track the current section, jump smoothly on click, and expand a label.

An IntersectionObserver tracks how much of each section is visible; the current dot scales up, is marked with a flowing outer ring, and expands a label to the side, and clicking one smoothly scrolls to that section — a good fit for navigating long pages or sectioned scroll containers.

Loading preview…
npx shadcn@latest add https://webberui.com/r/scroll-spy-dots.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<ScrollSpyDots />

Installation

npx shadcn@latest add https://webberui.com/r/scroll-spy-dots.json

Or, once registries are configured in components.json, install it as @webberui/scroll-spy-dots.

Usage

import { ScrollSpyDots } from "@/components/ui/scroll-spy-dots";

const sections = [
  { id: "story", label: "Our story" },
  { id: "features", label: "Core features" },
  { id: "contact", label: "Contact us" },
];

// The page must contain section elements with the matching ids
<div className="fixed right-6 top-1/2 -translate-y-1/2">
  <ScrollSpyDots sections={sections} />
</div>

If the sections live inside a custom scroll container, pass the container's ref to containerRef; both observation and jumping will then use that container as the scroll root:

const containerRef = React.useRef<HTMLDivElement>(null);

<div ref={containerRef} className="h-[400px] overflow-y-auto">
  {/* ...sections carrying ids... */}
</div>
<ScrollSpyDots sections={sections} containerRef={containerRef} />

Props

PropTypeDefaultDescription
sections{ id: string; label: string }[]The list of sections to track; id matches a page element and label is the expanded label
containerRefReact.RefObject<HTMLElement | null>Ref of a custom scroll container; when omitted, the browser window is the scroll root
labelSide"left" | "right""left"Direction the label expands in (relative to the dot)
alwaysShowLabelsbooleanfalseWhether every label stays visible; by default only the current one and hover/focus show a label
onActiveChange(id: string) => voidCallback fired when the current section changes
classNamestringForwarded to the outermost nav

Accessibility

  • Wrapped in a nav landmark labeled aria-label="Section navigation", so assistive technology can jump to it quickly
  • Every dot is a native button with the section label as its aria-label, and the current section is marked with aria-current
  • Keyboard support: Tab moves through them in order, Enter/Space jumps, and focusing also expands the label and shows a focus ring
  • When the user has "reduce motion" enabled at the system level, it switches to an instant jump (not smooth scrolling), and the outer ring, scale-up, and label animations are all disabled
  • The decorative dots, outer rings, and rail line are all marked aria-hidden so they do not interfere with reading

On this page