Anchor Tabs
Navigation

Anchor Tabs

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.

Install

npx shadcn@latest add @paragon/anchor-tabs

anchor-tabs.tsx

"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>
  );
}