Skip to content
Component installs need the registry setup
VegaStack Design

Tabs

A set of layered sections of content — known as tab panels — that are displayed one at a time.

Status
stable
Since
0.1.0
Accessibility pattern
APG tabs

Last updated

Overview
View your key metrics and recent project activity. Track progress across all your active projects.
You have 12 active projects and 3 pending tasks.

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 tabs

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/tabs

Run 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>;
Overview
View your key metrics and recent project activity. Track progress across all your active projects.
You have 12 active projects and 3 pending tasks.

Anatomy

Tabs — data-slot="tabs"
TabsList — data-slot="tabs-list"
TabsTrigger — data-slot="tabs-trigger"
TabsContent — data-slot="tabs-content"

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
└── TabsContent
Make changes to your account here.

Line

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>;
The meeting agreed the Q4 budget. Swipe left on a touch screen for the action items.

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.

Overview content
<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

PropTypeDefaultDescription
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 a role="tab" with aria-selected and aria-controls, and each panel a role="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 nav of links, and the current one carries aria-current="page".
  • The panel itself carries tabindex="0", so a panel whose content holds nothing focusable is still reachable. That is why TabsContent keeps 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.css draws the 2px ring outline 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.
ContractStates tested
Behaviourdefault, active, disabled
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation
Visualdefault, hover, active, disabled

Do / Don't

Do
Use Tabs for two to about seven peer views of the same subject (including scope/views like My tasks | Team tasks), and give each trigger a short noun label.
Don't
Use Tabs for a List/Board layout switch (that is ListToolbar's Layout tool), or as a wizard or for a sequence that must be completed in order, and do not put a form's submit inside one tab and its errors in another.

Deviations

Upstream's file plus packages/ui/upstream/patches/tabs.patch. Every hunk:

  • A11Y-20 — Tabs forwards orientation to Base UI's root as well as writing data-orientation, so a vertical list announces aria-orientation="vertical" and moves with ↑/↓. Upstream consumed the prop, so a vertical list navigated as a row.
  • LAY-14 — TabsList takes overflow="scroll" | "visible", defaulting to "scroll" for variant="line": a horizontal list scrolls inside itself with the AttachmentGroup edge 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 no data-orientation scrolls too. Widened (MK, 29-10-2026): overflow="menu" folds the list into one dropdown when it does not fit, measured by useOverflowFold.
  • API-25 — the trigger's class string is the exported tabsTriggerVariants recipe, beside upstream's tabsListVariants, 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 from TabsTrigger, and outline-none goes from TabsContent — Base UI gives the panel a tab stop, so suppressing the outline would leave a focusable element with no affordance at all.
  • API-29 — TabsList takes size="default" | "sm" (written as data-size); sm is the compact h-7 list for views, with px-2 triggers and 14px icons.
  • FRM-4 — disabled:pointer-events-none and aria-disabled:pointer-events-none are 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 loading prop 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 had loading either. The panel, not the tab, is where a pending state belongs.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance 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.

On this page