Componentnavigation
Scroll Spy V2
Horizontal scroll-spy nav v2 with sliding active pill.
Preview
Live Preview
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add scroll-spy-v2Or install the full npm package:
npm install bigbulluiUsage
import { ScrollSpyV2 } from "@/components/ui/scroll-spy-v2";
<ScrollSpyV2 />Source code
scroll-spy-v2.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface ScrollSpyV2Props extends React.HTMLAttributes<HTMLElement> {
sections: { id: string; label: string }[];
}
/** Yatay scroll-spy nav v2: aktif bölümün altında kayan pill. */
export function ScrollSpyV2({ sections, className, ...props }: ScrollSpyV2Props) {
const [active, setActive] = React.useState(sections[0]?.id);
const listRef = React.useRef<HTMLUListElement>(null);
const itemRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActive(entry.target.id);
}
},
{ rootMargin: "-30% 0px -60% 0px" }
);
for (const s of sections) {
const el = document.getElementById(s.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [sections]);
React.useLayoutEffect(() => {
const btn = itemRefs.current[active];
const list = listRef.current;
if (btn && list) {
list.scrollTo({ left: btn.offsetLeft - list.clientWidth / 2 + btn.clientWidth / 2, behavior: "smooth" });
}
}, [active]);
return (
<nav className={cn("w-full border-b border-border", className)} aria-label="Bölümler" {...props}>
<ul ref={listRef} className="flex gap-1 overflow-x-auto px-2" style={{ scrollbarWidth: "none" }}>
{sections.map((s) => {
const isActive = active === s.id;
return (
<li key={s.id} className="relative shrink-0">
<button
ref={(el) => { itemRefs.current[s.id] = el; }}
onClick={() => { setActive(s.id); document.getElementById(s.id)?.scrollIntoView({ behavior: "smooth" }); }}
className={cn(
"px-3 py-2 text-xs transition-colors motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-t-sm",
isActive ? "font-medium text-accent" : "text-muted-foreground hover:text-foreground"
)}
>
{s.label}
</button>
{isActive && <span className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-accent animate-[fade-in_0.2s_ease-out] motion-reduce:animate-none" aria-hidden="true" />}
</li>
);
})}
</ul>
</nav>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| sections | { id: string; label: string }[] |