Sticky in-page section tabs with a scroll-spy that reads through a band below the bar, a sliding measured underline, click-lock so the indicator never hops, and hash sync via replaceState.
npx shadcn@latest add @paragon/anchor-tabs"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AnchorTabsItem {
/** id of the section element this tab tracks. */
id: string;
label: string;
}
export interface AnchorTabsProps extends React.ComponentProps<"nav"> {
items: AnchorTabsItem[];
/** Scroll container the sections live in; defaults to the viewport. */
containerRef?: React.RefObject<HTMLElement | null>;
/**
* Height in px reserved above sections when scrolling to them — set it to
* the sticky bar's height. Also shifts the scroll-spy reading band.
*/
offset?: number;
/** Mirror the active section into the URL hash via replaceState. */
syncHash?: boolean;
/** Render the bar sticky at the top of its scroll context. */
sticky?: boolean;
}
/**
* Sticky in-page section tabs. An IntersectionObserver reads sections
* through a band just below the bar (so "active" matches what you're
* reading); clicking scrolls to the section minus the bar offset and locks
* the spy so the underline doesn't hop through intermediate sections. The
* underline is one measured element that slides and retargets; the URL hash
* follows along via replaceState — no history spam.
*/
export function AnchorTabs({
items,
containerRef,
offset = 48,
syncHash = true,
sticky = true,
className,
...props
}: AnchorTabsProps) {
const [activeId, setActiveId] = React.useState<string | undefined>(
items[0]?.id,
);
const navRef = React.useRef<HTMLElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const clickLock = React.useRef<{
id: string;
timer: ReturnType<typeof setTimeout>;
} | null>(null);
const itemsKey = items.map((item) => item.id).join(" ");
/* ------------------------------- scroll spy ----------------------------- */
React.useEffect(() => {
const ids = itemsKey.split(" ").filter(Boolean);
const sections = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (sections.length === 0) return;
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
const next = ids.find((id) => visible.has(id));
if (!next) return;
if (clickLock.current) {
if (next !== clickLock.current.id) return;
clearTimeout(clickLock.current.timer);
clickLock.current = null;
}
setActiveId(next);
},
{
root: containerRef?.current ?? null,
rootMargin: `${-offset}px 0% -55% 0%`,
},
);
for (const section of sections) observer.observe(section);
return () => observer.disconnect();
}, [itemsKey, containerRef, offset]);
// Restore from an incoming hash once, instantly.
React.useEffect(() => {
if (!syncHash) return;
const id = window.location.hash.slice(1);
if (!id || !itemsKey.split(" ").includes(id)) return;
const target = document.getElementById(id);
if (!target) return;
setActiveId(id);
scrollToSection(target, "auto");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Mirror the active section into the hash.
React.useEffect(() => {
if (!syncHash || !activeId) return;
if (window.location.hash.slice(1) === activeId) return;
window.history.replaceState(null, "", `#${activeId}`);
}, [syncHash, activeId]);
React.useEffect(() => {
return () => {
if (clickLock.current) clearTimeout(clickLock.current.timer);
};
}, []);
/* ------------------------------- indicator ------------------------------ */
const position = React.useCallback((animate: boolean) => {
const nav = navRef.current;
const indicator = indicatorRef.current;
if (!nav || !indicator) return;
const active = nav.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
indicator.style.opacity = "0";
return;
}
if (!animate) indicator.style.transitionProperty = "none";
indicator.style.opacity = "1";
indicator.style.width = `${active.offsetWidth}px`;
indicator.style.transform = `translateX(${active.offsetLeft}px)`;
if (!animate) {
void indicator.offsetWidth;
indicator.style.transitionProperty = "";
}
}, []);
React.useLayoutEffect(() => {
position(hasPositioned.current);
hasPositioned.current = true;
}, [position, activeId]);
React.useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(nav);
return () => observer.disconnect();
}, [position]);
/* -------------------------------- scrolling ----------------------------- */
const scrollToSection = React.useCallback(
(target: HTMLElement, behavior: ScrollBehavior) => {
const container = containerRef?.current;
if (container) {
const top =
target.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop -
offset;
container.scrollTo({ top, behavior });
} else {
const top =
target.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior });
}
},
[containerRef, offset],
);
const onTabClick = (
event: React.MouseEvent<HTMLAnchorElement>,
id: string,
) => {
event.preventDefault();
const target = document.getElementById(id);
if (!target) return;
setActiveId(id);
if (syncHash) window.history.replaceState(null, "", `#${id}`);
if (clickLock.current) clearTimeout(clickLock.current.timer);
clickLock.current = {
id,
timer: setTimeout(() => {
clickLock.current = null;
}, 1000),
};
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
scrollToSection(target, reduced ? "auto" : "smooth");
};
return (
<nav
ref={navRef}
data-slot="anchor-tabs"
aria-label="Page sections"
className={cn(
"relative z-10 w-full border-b bg-background/85 backdrop-blur-sm",
sticky && "sticky top-0",
className,
)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className="pointer-events-none absolute -bottom-px left-0 h-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,width] duration-200 ease-out motion-reduce:transition-none"
/>
<div className="flex items-center gap-1 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
data-active={item.id === activeId || undefined}
aria-current={item.id === activeId ? "location" : undefined}
onClick={(event) => onTabClick(event, item.id)}
className="flex h-10 shrink-0 items-center rounded-md px-3 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors duration-150 ease-out outline-none select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-active:text-foreground"
>
{item.label}
</a>
))}
</div>
</nav>
);
}