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.
npx shadcn@latest add https://webberui.com/r/scroll-spy-dots.jsonPlayground
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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
sections | { id: string; label: string }[] | — | The list of sections to track; id matches a page element and label is the expanded label |
containerRef | React.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) |
alwaysShowLabels | boolean | false | Whether every label stays visible; by default only the current one and hover/focus show a label |
onActiveChange | (id: string) => void | — | Callback fired when the current section changes |
className | string | — | Forwarded to the outermost nav |
Accessibility
- Wrapped in a
navlandmark labeledaria-label="Section navigation", so assistive technology can jump to it quickly - Every dot is a native
buttonwith the section label as itsaria-label, and the current section is marked witharia-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-hiddenso they do not interfere with reading