Tabs
A set of layered sections of content — known as tab panels — that are displayed one at a time.
- Status
- Since
0.1.0- Accessibility pattern
- APG tabs
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Tabs before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify tabsInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/tabsRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/dropdown-menu, @vegastack/use-overflow-fold.
Usage
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
<Tabs defaultValue="account" className="w-[400px]">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Make changes to your account here.</TabsContent>
<TabsContent value="password">Change your password here.</TabsContent>
</Tabs>;Anatomy
Examples
Composition
TabsList holds the triggers and TabsContent holds one panel per value, so the tree is the two
halves side by side under one root.
Tabs
├── TabsList
│ ├── TabsTrigger
│ └── TabsTrigger
├── TabsContent
└── TabsContentLine
Use the variant="line" prop on TabsList for a line style. The default is variant="default",
the grey pill track — the two are the whole variant set.
Views and scope
Views of one list — My tasks | Team tasks — are Tabs: the default (segmented) variant, not
line, at the compact size="sm" (h-7), with optional leading icons. This is what a
ListToolbar's tabs slot takes. A layout switch (List | Board) is not a view of different
rows, so it is the ListToolbar's Layout tool.
<Tabs value={scope} onValueChange={setScope}>
<TabsList size="sm" aria-label="Scope">
<TabsTrigger value="mine">
<User />
My tasks
</TabsTrigger>
<TabsTrigger value="team">
<Users />
Team tasks
</TabsTrigger>
</TabsList>
</Tabs>Vertical
Use orientation="vertical" on Tabs for vertical tabs. The root writes data-orientation onto
itself, and the group-data-vertical/tabs: variants on the list and the trigger read it to stack the
tabs, fill the width, and move the active indicator from the bottom edge to the inline end.
The prop also reaches Base UI's root (A11Y-20), so the list announces aria-orientation="vertical"
and the roving keyboard axis turns with it: ↑/↓ move between tabs, and
←/→ do nothing. Reach for a vertical list where the label column is the point — a settings page with a stable set of sections.
Vertical in a narrow container
A vertical list needs room beside its panel. Below an @md container width (448px) switch the
same tabs to a horizontal line list, which scrolls inside itself instead of widening the page
(LAY-14). The orientation is a prop, not a style, because it also turns the keyboard axis and
aria-orientation, so measure the container and keep the tabs hidden (visibility, which keeps
their box) until it is measured, so the first paint is already the right orientation:
const [ref, wide] = useWiderThan(448); // a ResizeObserver on the wrapper, null until measured
<div ref={ref} style={{ visibility: wide === null ? "hidden" : undefined }}>
<Tabs orientation={wide ? "vertical" : "horizontal"} defaultValue="general">
<TabsList variant="line" aria-label="Family settings">
…
</TabsList>
…
</Tabs>
</div>;Disabled
disabled on a TabsTrigger takes it out of the roving tab sequence and dims it. It keeps its
pointer events (FRM-4), so a Tooltip wrapped around it can still explain why it is unavailable.
Icons
A TabsTrigger lays its children out in a row with a gap, and sizes a bare svg child to size-4,
so an icon beside the label needs no wrapper and no class.
RTL
Wrap the subtree in DirectionProvider direction="rtl" so Base UI resolves the arrow keys and the
logical edges. The trigger's active indicator is positioned with -end/inset-x, so it follows the
reading direction without a second rule.
With counts
A count goes inside the trigger, after the label, so it is part of the tab's accessible name. Show
the number in a muted tabular-nums span marked aria-hidden, and follow it with an sr-only
suffix that says what it counts, so a screen reader hears "Open 12 tasks" rather than "Open12" or a
bare number. Leave the suffix without a leading comma: the trigger lays its children out in a row,
so a separate span is already read as its own word.
<TabsTrigger value="open">
Open
<span aria-hidden className="text-muted-foreground tabular-nums">
12
</span>
<span className="sr-only">12 tasks</span>
</TabsTrigger>Many tabs
TabsList takes overflow: "scroll" or "visible". A line list defaults to "scroll" (LAY-14):
the row scrolls sideways inside itself, never the page, with the same edge fades and hidden
scrollbar as AttachmentGroup. The selected tab is kept in view on mount and whenever the
selection changes, and the keyboard's roving stop scrolls in as it moves. The pill track defaults to
"visible", upstream's behaviour; pass overflow="scroll" to opt it in.
overflow="menu" folds the list instead: when the tabs do not fit the room they are given
(measured, not a breakpoint) the list becomes one ghost dropdown — the active tab's own content and
▾ — whose menu lists every tab; picking one selects it. The list stays mounted while folded, so the
value stays Base UI's. It unfolds only with 24px to spare, so it never flickers. This is the scope
tabs' mode in a ListToolbar, where scope tabs never scroll.
Full width with swipe
The default variant stretched across its column (className="w-full" on TabsList) is the
record-page pattern: Summary · Action items · Transcript over full-width panels with no card
around them. On touch, useTabsSwipe lets a horizontal swipe on the panels move to the
neighbouring tab. Spread its result on the element that wraps the TabsContent panels; only touch
pointers count, a mostly vertical gesture stays a scroll, and the direction follows RTL. The hook
is a separate registry item (shadcn add @vegastack/use-tabs-swipe), so Tabs itself stays
upstream's file.
const swipe = useTabsSwipe({
values: ["summary", "actions", "transcript"],
value,
onValueChange: setValue,
});
<Tabs value={value} onValueChange={setValue}>
<TabsList className="w-full">…</TabsList>
<div {...swipe}>
<TabsContent value="summary">…</TabsContent>…
</div>
</Tabs>;Route tabs
When each tab is its own page, the tabs are navigation, not a tablist: render a named nav of links
and mark the current one with aria-current="page". The two recipes, tabsListVariants and
tabsTriggerVariants (API-25), draw it exactly like the line tabs beside it. The trigger recipe
paints data-active and reads the list's data-variant, so set that and give the nav the
group/tabs class. Neither needs an orientation: with none, the list scrolls as a horizontal one
(overflow: "scroll" — max-w-full, overflow-x-auto) and the active link draws the horizontal
line indicator. Only data-orientation="vertical" — on the nav and the list — switches both to
the vertical layout.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { tabsListVariants, tabsTriggerVariants } from "@/components/ui/tabs";
function ProjectTabs({
routes,
}: {
routes: { href: string; label: string }[];
}) {
const pathname = usePathname();
return (
<nav aria-label="Project" className="group/tabs">
<div
data-variant="line"
className={tabsListVariants({ variant: "line", overflow: "scroll" })}
>
{routes.map((route) => {
const current = pathname === route.href;
return (
<Link
key={route.href}
href={route.href}
aria-current={current ? "page" : undefined}
data-active={current ? "" : undefined}
className={tabsTriggerVariants()}
>
{route.label}
</Link>
);
})}
</div>
</nav>
);
}Route tabs can run vertically too — set data-orientation="vertical" on the nav and the list —
and a list of more than a few routes, or a narrow column, jumps with a NativeSelect instead: below
the @md container width the select replaces the links, so a phone opens its own picker. Both
live in the markup and a container query shows one, so nothing is measured. Put a count in a
route's name the way a tab carries one: the visible number aria-hidden, a spoken suffix beside it.
<div className="@container">
<NativeSelect
aria-label="Picklist"
className="@md:hidden"
value={pathname}
onChange={(event) => router.push(event.target.value)}
>
{routes.map((r) => (
<NativeSelectOption
key={r.href}
value={r.href}
>{`${r.label} (${r.count})`}</NativeSelectOption>
))}
</NativeSelect>
<nav
aria-label="Picklists"
data-orientation="vertical"
className="group/tabs hidden @md:block"
>
<div
data-orientation="vertical"
data-variant="line"
className={`${tabsListVariants({ variant: "line" })} w-full`}
>
{/* the same links as above, each ending in its count */}
</div>
</nav>
</div>Playground
Switch the list between its two variants and the root between its two orientations, then copy the generated JSX.
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview">Overview content</TabsContent>
<TabsContent value="activity">Activity content</TabsContent>
<TabsContent value="settings">Settings content</TabsContent>
</Tabs>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
overflow | "menu" | "scroll" | "visible" | null | — | |
size | "default" | "sm" | null | — | |
variant | "default" | "line" | null | — |
TabsList adds variant ("default" or "line"), size ("default" or "sm", the compact h-7
list for views) and overflow ("scroll" or "visible",
defaulting to "scroll" for variant="line" and "visible" otherwise). Tabs, TabsTrigger and
TabsContent add no props of their own — each accepts everything the corresponding Base UI Tabs
part accepts (Root, Tab, Panel): value / defaultValue / onValueChange and orientation
on the root, value and disabled on a tab, value and keepMounted on a panel, plus
className, ref and render everywhere.
Two class recipes are exported for markup that is not a Base UI tablist: tabsListVariants({ variant, overflow }) and tabsTriggerVariants(), the trigger's whole class string. The route-tabs recipe
above is what they are for.
Accessibility
- Renders Base UI's Tabs, which implements the APG tabs pattern: the list is a
role="tablist", each trigger arole="tab"witharia-selectedandaria-controls, and each panel arole="tabpanel"labelled by its tab. - Keyboard navigation is a roving tab index — one stop for the whole list, then
←/→ (or ↑/↓ in a vertical list,
which says so with
aria-orientation="vertical"), Home and End inside it, and Enter or Space to activate. Activation is manual, not on focus, so arrowing through a list does not swap panels under the reader. A disabled tab is skipped. - A scrolling list (
overflow="scroll") keeps the tab under the keyboard in view as the roving stop moves, and keeps the selected tab in view when the selection changes from outside. - Route tabs are not a tablist: they are a named
navof links, and the current one carriesaria-current="page". - The panel itself carries
tabindex="0", so a panel whose content holds nothing focusable is still reachable. That is whyTabsContentkeeps its outline: suppressing it would leave a focusable element with no affordance (FOC-6). - Tabs are the one component that keeps a focus ring (FOC-13):
base.cssdraws the 2pxringoutline on[role=tab]and[role=tabpanel]; every other control shows a background tint. - A disabled trigger keeps its pointer events (FRM-4) so a tooltip explaining the state can still be hovered.
| Contract | States tested |
|---|---|
| Behaviour | default, active, disabled |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation |
| Visual | default, hover, active, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/tabs.patch. Every hunk:
- A11Y-20 —
Tabsforwardsorientationto Base UI's root as well as writingdata-orientation, so a vertical list announcesaria-orientation="vertical"and moves with ↑/↓. Upstream consumed the prop, so a vertical list navigated as a row. - LAY-14 —
TabsListtakesoverflow="scroll" | "visible", defaulting to"scroll"forvariant="line": a horizontal list scrolls inside itself with theAttachmentGroupedge fades, starts at the inline start, pulls the line indicator 1px up so the scroll box does not clip it, and keeps the selected tab in view when the selection changes. The scroll box applies to every list that is not vertical, so a route list with nodata-orientationscrolls too. Widened (MK, 29-10-2026):overflow="menu"folds the list into one dropdown when it does not fit, measured byuseOverflowFold. - API-25 — the trigger's class string is the exported
tabsTriggerVariantsrecipe, beside upstream'stabsListVariants, so route tabs can be drawn from the system's own classes. - FOC-1, FOC-6 — the four
focus-visible:classes (border-ring,ring-[3px],ring-ring/50,outline-1 outline-ring) go fromTabsTrigger, andoutline-nonegoes fromTabsContent— Base UI gives the panel a tab stop, so suppressing the outline would leave a focusable element with no affordance at all. - API-29 —
TabsListtakessize="default" | "sm"(written asdata-size);smis the compact h-7 list for views, withpx-2triggers and 14px icons. - FRM-4 —
disabled:pointer-events-noneandaria-disabled:pointer-events-noneare dropped, so a disabled tab stays hoverable and a Tooltip can explain why. Both opacity halves stay. - API-5 — NO HUNK, and that is the audit the plan asks for. A
loadingprop belongs on a control that commits something and must hold its box while it does — Button and Toggle. A tab reveals a panel that is already mounted; the pre-reset Tabs never hadloadingeither. The panel, not the tab, is where a pending state belongs. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own. - A11Y-24 — a trigger's invisible hit area reaches 44px tall on a coarse pointer, never wider, so neighbouring tabs never overlap. Widened by MK, 2026-10-26.