Section nav
A sticky row of anchor links that underlines the section in view.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; label: string }[] | The sections in page order. id must match the section's id attribute. | |
| activeId | string | Controlled active id. Set it and the scroll spy switches off. | |
| root | RefObject<HTMLElement | null> | A scrolling element to spy on and scroll, instead of the window. | |
| sticky | boolean | true | Sticks to the top of its scroll container. |
Source
"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