Sticky in-page tabs that follow the reader's section, slide an indicator to it, keep the active tab in view on narrow screens and scroll to a section on click.
Overview
Section 1. The tab bar sticks to the top, follows your position and scrolls to a section when you choose its tab.
Features
Section 2. The tab bar sticks to the top, follows your position and scrolls to a section when you choose its tab.
Pricing
Section 3. The tab bar sticks to the top, follows your position and scrolls to a section when you choose its tab.
FAQ
Section 4. The tab bar sticks to the top, follows your position and scrolls to a section when you choose its tab.
"use client";
import * as React from "react";
import { SectionTabs } from "@/components/ballmac/section-tabs";
const sections = [
{ id: "st-overview", label: "Overview" },
{ id: "st-features", label: "Features" },
{ id: "st-pricing", label: "Pricing" },
{ id: "st-faq", label: "FAQ" },
];
export default function SectionTabsDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} className="h-80 w-full max-w-2xl overflow-auto rounded-xl border bg-background shadow-sm">
<SectionTabs sections={sections} container={scroller} offset={56} />
{sections.map((s, i) => (
<section key={s.id} id={s.id} className="grid min-h-56 content-start gap-2 border-b p-6 last:border-b-0">
<h2 className="text-xl font-semibold tracking-tight">{s.label}</h2>
<p className="max-w-prose text-sm text-muted-foreground">
Section {i + 1}. The tab bar sticks to the top, follows your position and scrolls to a section when you choose its tab.
</p>
</section>
))}
<div className="h-32" aria-hidden="true" />
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/section-tabsInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/scroll @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/section-tabs.tsx// Ballmac UI: Section Tabs. https://ui.ballmac.com/components/section-tabs "use client"; import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; import { scrollToId, useScrollSpy, useScrolled, type ScrollContainer } from "@/lib/ballmac/scroll"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type SectionTab = { /** The id of the section in the page. */ id: string; /** Tab text. */ label: string; }; type SectionTabsProps = Omit<React.ComponentProps<"nav">, "children"> & { /** Sections in page order. */ sections: SectionTab[]; /** Accessible name of the navigation landmark. */ label?: string; /** Pixels of space to keep above a section after scrolling to it (your sticky header plus this bar). */ offset?: number; /** A scrollable element to watch and scroll instead of the page. */ container?: ScrollContainer; /** Indicator style. */ variant?: "underline" | "pill"; /** Stick to the top of the page (or container) while scrolling. */ sticky?: boolean; /** Distance from the top when sticky. */ stickyTop?: number; }; /** * In-page navigation for long pages (pricing, product, docs). Tabs follow the reader: the current section is * highlighted with a sliding indicator, the active tab scrolls into view on narrow screens, and choosing a tab * scrolls to its section. Real links, so it works with the keyboard, new tabs and copied URLs. */ function SectionTabs({ sections, label, offset = 72, container, variant = "underline", sticky = true, stickyTop = 0, className, style, ...props }: SectionTabsProps) { const msg = useMessages() label ??= msg("section-tabs.label", "Sections") const reduce = useReducedMotion(); const indicatorId = `section-tabs-${React.useId()}`; const ids = React.useMemo(() => sections.map((s) => s.id), [sections]); const active = useScrollSpy(ids, { offset, container }); const stuck = useScrolled(offset, container); const listRef = React.useRef<HTMLUListElement>(null); React.useEffect(() => { const list = listRef.current; const el = list?.querySelector<HTMLElement>("[aria-current=location]"); if (!list || !el) return; const left = el.offsetLeft - (list.clientWidth - el.offsetWidth) / 2; if (typeof list.scrollTo === "function") list.scrollTo({ left, behavior: reduce ? "auto" : "smooth" }); }, [active, reduce]); return ( <nav aria-label={label} data-slot="section-tabs" data-stuck={stuck || undefined} style={{ top: sticky ? stickyTop : undefined, ...style }} className={cn( "z-30 border-b bg-background/85 backdrop-blur-md transition-shadow duration-200 data-[stuck]:shadow-[0_8px_24px_-16px_rgb(0_0_0/0.2)] motion-reduce:transition-none", sticky && "sticky", className, )} {...props} > <ul ref={listRef} className="mx-auto flex max-w-5xl items-center gap-1 overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > {sections.map((section) => { const isActive = section.id === active; return ( <li key={section.id} className="shrink-0"> <a href={`#${section.id}`} aria-current={isActive ? "location" : undefined} onClick={(event) => { event.preventDefault(); if (scrollToId(section.id, { offset: offset - 4, container })) history.replaceState(null, "", `#${section.id}`); }} className={cn( "relative my-1.5 inline-flex h-9 items-center rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50", isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground", )} > {isActive && ( <motion.span layoutId={indicatorId} aria-hidden="true" transition={reduce ? { duration: 0 } : spring.snappy} className={cn( "absolute", variant === "underline" ? "inset-x-2 -bottom-[7px] h-0.5 rounded-full bg-primary" : "inset-0 -z-10 rounded-md bg-accent", )} /> )} {section.label} </a> </li> ); })} </ul> </nav> ); } export { SectionTabs, type SectionTabsProps, type SectionTab };Update the import paths to match your project setup.
Usage
import { SectionTabs } from "@/components/ballmac/section-tabs"The full example is in the Code tab above.
Examples
Pill, narrow
Details
Narrow screens scroll the tabs sideways and keep the active one centered.
Specs
Narrow screens scroll the tabs sideways and keep the active one centered.
Reviews
Narrow screens scroll the tabs sideways and keep the active one centered.
Shipping
Narrow screens scroll the tabs sideways and keep the active one centered.
Returns
Narrow screens scroll the tabs sideways and keep the active one centered.
Support
Narrow screens scroll the tabs sideways and keep the active one centered.
"use client";
import * as React from "react";
import { SectionTabs } from "@/components/ballmac/section-tabs";
const sections = ["Details", "Specs", "Reviews", "Shipping", "Returns", "Support"].map((label) => ({ id: `sts-${label.toLowerCase()}`, label }));
export default function SectionTabsStates() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} className="h-64 w-full max-w-xs overflow-auto rounded-xl border bg-background">
<SectionTabs sections={sections} container={scroller} offset={56} variant="pill" label="Product sections" />
{sections.map((s) => (
<section key={s.id} id={s.id} className="min-h-40 border-b p-4">
<h2 className="font-semibold">{s.label}</h2>
<p className="text-sm text-muted-foreground">Narrow screens scroll the tabs sideways and keep the active one centered.</p>
</section>
))}
<div className="h-24" aria-hidden="true" />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
sections*Sections in page order. | SectionTab[] | — |
labelAccessible name of the navigation landmark. | string | — |
offsetPixels of space to keep above a section after scrolling to it (your sticky header plus this bar). | number | 72 |
containerA scrollable element to watch and scroll instead of the page. | ScrollContainer | — |
variantIndicator style. | "underline" | "pill" | "underline" |
stickyStick to the top of the page (or container) while scrolling. | boolean | true |
stickyTopDistance from the top when sticky. | number | 0 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter | Scrolls to the section and updates the URL hash |
| Tab | Real links; middle-click and copy link work |
| Screen readers | Current section has aria-current=location |
Use with AI
sections: {id,label} matching element ids in the page. offset is the space to leave for this bar and any header. variant underline or pill. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Section Tabs (@ballmac/section-tabs) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Long product, pricing and docs pages
- Anchored sections of a settings page
Not for
- Switching views without scrolling; use tabs
- Vertical page outlines; use table-of-contents
Registry JSON: https://ui.ballmac.com/r/section-tabs.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Table Of Contents
An 'On this page' list with a scroll spy and a sliding current-section marker. It collects headings itself or takes a list, and scrolls below sticky headers.
Navbar
A responsive site header that gains a border and shadow on scroll, can hide while scrolling down, and turns its links into a sheet menu on small screens.
Tabs
Keyboard-navigable content tabs with quiet pill or underline styling and a clear selected state in both themes.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.