Skip to content
goodtabs UI
BrowseBrowse components

Section nav

A sticky row of anchor links that underlines the section in view.

npx shadcn@latest add https://ui.goodtabs.co/r/section-nav.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/section-nav

Overview

Northwind is a small team of twelve shipping a billing product for agencies. They moved to goodtabs UI in a week and kept every screen on the same tokens.

Features

Invoices, recurring plans, usage metering and a customer portal. Everything exports to the accounting tools agencies already use.

Pricing

Free for the first three clients, then a flat monthly fee per workspace. No per-seat charge, no transaction cut.

FAQ

Most questions are about migration. Import from Stripe takes an afternoon and nothing is charged twice. Support answers within a day.

Scroll the box and the underline moves. Click a link to jump to its section.

Controlled

activeId set by hand, not sticky, inside a frame.

Many items

Eight links in a narrow frame scroll sideways instead of wrapping.

Usage

import { SectionNav } from "@/components/ui/section-nav"

<SectionNav
  items={[
    { id: "overview", label: "Overview" },
    { id: "features", label: "Features" },
    { id: "pricing", label: "Pricing" },
  ]}
/>

Props

PropTypeDefaultDescription
items{ id: string; label: string }[]The sections in page order. id must match the section's id attribute.
activeIdstringControlled active id. Set it and the scroll spy switches off.
rootRefObject<HTMLElement | null>A scrolling element to spy on and scroll, instead of the window.
stickybooleantrueSticks to the top of its scroll container.

Source

section-nav/section-nav.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type SectionNavItem = {
  /** The id of the section on the page. */
  id: string;
  label: string;
};

type SectionNavProps = Omit<React.ComponentProps<"nav">, "children"> & {
  /** The sections, in page order. */
  items: SectionNavItem[];
  /** Controlled active id. Omit to let the scroll spy decide. */
  activeId?: string;
  /** A scrolling element to spy on and scroll, instead of the window. */
  root?: React.RefObject<HTMLElement | null>;
  /** Sticks to the top of its scroll container. */
  sticky?: boolean;
};

const BAR_HEIGHT = 40;
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;

/**
 * A sticky row of anchor links to the sections of one page. The active section gets a 2px underline.
 * Scrolls sideways on phones instead of wrapping.
 */
export function SectionNav({ items, activeId, root, sticky = true, className, ...props }: SectionNavProps) {
  const spied = useScrollSpy(items, root, sticky ? BAR_HEIGHT : 0, activeId === undefined);
  const active = activeId ?? spied;

  const jump = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
    const scroller = root?.current;
    if (!scroller) return;
    const section = scroller.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
    if (!section) return;
    event.preventDefault();
    const top =
      scroller.scrollTop +
      section.getBoundingClientRect().top -
      scroller.getBoundingClientRect().top -
      (sticky ? BAR_HEIGHT : 0);
    scroller.scrollTo({ top, behavior: reducedMotion() ? "auto" : "smooth" });
  };

  return (
    <nav
      data-slot="section-nav"
      aria-label="Sections"
      className={cn(
        "z-10 h-10 w-full border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/80",
        sticky && "sticky top-0",
        className,
      )}
      {...props}
    >
      <ul className="m-0 -mb-px flex h-10 [scrollbar-width:none] list-none items-stretch gap-1 overflow-x-auto px-2 whitespace-nowrap [&::-webkit-scrollbar]:hidden">
        {items.map((item) => {
          const isActive = item.id === active;
          return (
            <li key={item.id} className="flex shrink-0">
              <a
                href={`#${item.id}`}
                aria-current={isActive ? "location" : undefined}
                onClick={(event) => jump(event, item.id)}
                className={cn(
                  "flex items-center border-b-2 px-2 text-sm transition-colors duration-100 ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
                  isActive ? "border-foreground text-foreground" : "border-transparent text-muted-foreground",
                )}
              >
                {item.label}
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

/** The first section inside the top 40% of the scroll area, below the bar, wins. The last winner stays otherwise. */
function useScrollSpy(items: SectionNavItem[], root: SectionNavProps["root"], offset: number, enabled: boolean) {
  const [active, setActive] = React.useState<string | undefined>(undefined);
  const key = items.map((item) => item.id).join("\n");

  React.useEffect(() => {
    if (!enabled || !key) return;
    const ids = key.split("\n");
    const scope: ParentNode = root?.current ?? document;
    const sections = ids
      .map((id) => scope.querySelector<HTMLElement>(`#${CSS.escape(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 first = ids.find((id) => visible.has(id));
        if (first) setActive(first);
      },
      { root: root?.current ?? null, rootMargin: `-${offset}px 0px -60% 0px`, threshold: 0 },
    );
    for (const section of sections) observer.observe(section);
    return () => observer.disconnect();
  }, [key, root, offset, enabled]);

  return active;
}

For AI agents

Hand your agent this page, the markdown at /components/section-nav/prompt.md, or the Copy AI prompt button above.

When to use

  • A long product, settings or profile page with three to eight named sections
  • Under a page header, so people can jump within the page without a sidebar
  • When the links must fit on a phone: it scrolls sideways instead of wrapping

When not to use

  • For navigating between pages, use the navbar or a sidebar
  • For nested headings in docs, use the table of contents rail
  • When the page has one or two sections: a heading is enough

Agent notes

  • Client component. The sections must already carry the ids you list; wrap each in an element with that id
  • The spy allows for its own 40px height when sticky, and watches the top 40% of the scroll area below it
  • With root set, clicks scroll that element; without it they are plain anchors, so give sections scroll-mt-10
  • Pass sticky={false} inside a demo frame or when another sticky header already sits at the top