# Tabs

Switch between associated content panels.

Project overview tabs

[Complete TSX file](/examples/tabs/default.tsx)

```tsx
"use client";

import {
  Card,
  CardDescription,
  CardHeader,
  CardPanel,
  CardTitle,
} from "@/siteplane/components/card";
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

const sections = [
  {
    value: "overview",
    label: "Overview",
    description: "Key figures and recent activity for this project.",
    summary: "12 open tasks, 3 due this week.",
  },
  {
    value: "analytics",
    label: "Analytics",
    description: "Traffic and engagement over the last 30 days.",
    summary: "Page views are up 18% on the previous period.",
  },
  {
    value: "reports",
    label: "Reports",
    description: "Scheduled and exported reports.",
    summary: "Five reports are ready to download.",
  },
  {
    value: "settings",
    label: "Settings",
    description: "Notifications, access and project defaults.",
    summary: "Changes apply to every member of this project.",
  },
];

export default function Example() {
  return (
    <Tabs className="w-full max-w-md" defaultValue="overview">
      <TabsList aria-label="Project sections">
        {sections.map((section) => (
          <TabsTab key={section.value} value={section.value}>
            {section.label}
          </TabsTab>
        ))}
      </TabsList>
      {sections.map((section) => (
        <TabsPanel key={section.value} value={section.value}>
          <Card>
            <CardHeader>
              <CardTitle>{section.label}</CardTitle>
              <CardDescription>{section.description}</CardDescription>
            </CardHeader>
            <CardPanel>
              <p className="text-muted-foreground text-sm">{section.summary}</p>
            </CardPanel>
          </Card>
        </TabsPanel>
      ))}
    </Tabs>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/tabs
```

With npm: `npx shadcn@4.18.0 add @siteplane/tabs`. [Registry JSON](/registry/tabs.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/card
```

## Usage

```tsx
import {
  Tabs,
  TabsList,
  TabsTab,
  TabsPanel,
} from "@/siteplane/components/tabs";

<Tabs defaultValue="overview">
  <TabsList>
    <TabsTab value="overview">Overview</TabsTab>
  </TabsList>
  <TabsPanel value="overview">Content</TabsPanel>
</Tabs>;
```

## Composition

```text
Tabs
├── TabsRail (optional)
│   └── TabsList
│       └── TabsTab / TabsTrigger
└── TabsPanel / TabsContent
```

`TabsRail` puts an adjacent action in the same bar as the list, with one shared indicator. [Filter Bar](/docs/components/filter-bar/#search-in-the-tab-bar) builds on it for a Search segment that opens into a field.

## List variants

Set `variant` on `TabsList`: `default`, `pill` or `underline`. The underline variant can also draw a line under all tabs.

Tab list variants

[Complete TSX file](/examples/tabs/variants.tsx)

```tsx
"use client";

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

export default function Example() {
  return (
    <div className="grid gap-5">
      {(["default", "pill", "underline"] as const).map((variant) => (
        <Tabs key={variant} defaultValue="overview">
          <TabsList variant={variant} aria-label="Project sections">
            <TabsTab value="overview">Overview</TabsTab>
            <TabsTab value="activity">Activity</TabsTab>
            <TabsTab value="billing" disabled>
              Billing
            </TabsTab>
          </TabsList>
          <TabsPanel value="overview">
            <p className="text-muted-foreground text-sm">
              Key figures for this project.
            </p>
          </TabsPanel>
          <TabsPanel value="activity">
            <p className="text-muted-foreground text-sm">No recent activity.</p>
          </TabsPanel>
        </Tabs>
      ))}
    </div>
  );
}
```

## Underline with a divider

Set `divider` on an underline `TabsList` to draw a hairline under all tabs; the active underline sits on it. The line spans the list, so `w-full` runs it across the container while the tabs stay at the start. Vertical tabs draw it along their side.

Underline tabs with a divider

[Complete TSX file](/examples/tabs/divider.tsx)

```tsx
"use client";

import { Badge } from "@/siteplane/components/badge";
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

export default function Example() {
  return (
    <Tabs className="w-full max-w-md" defaultValue="details">
      <TabsList
        aria-label="Account settings"
        className="w-full"
        divider
        variant="underline"
      >
        <TabsTab value="details">Details</TabsTab>
        <TabsTab value="team">Team</TabsTab>
        <TabsTab value="notifications">
          Notifications{" "}
          <Badge size="sm" variant="secondary">
            2<span className="sr-only"> unread</span>
          </Badge>
        </TabsTab>
        <TabsTab value="billing">Billing</TabsTab>
      </TabsList>
      <TabsPanel value="details">
        <p className="text-muted-foreground text-sm">
          Your name, photo and contact email.
        </p>
      </TabsPanel>
      <TabsPanel value="team">
        <p className="text-muted-foreground text-sm">
          Members of this workspace and their roles.
        </p>
      </TabsPanel>
      <TabsPanel value="notifications">
        <p className="text-muted-foreground text-sm">
          Two unread updates from your team.
        </p>
      </TabsPanel>
      <TabsPanel value="billing">
        <p className="text-muted-foreground text-sm">
          Your plan, payment method and invoices.
        </p>
      </TabsPanel>
    </Tabs>
  );
}
```

## With icons

Place a decorative icon before the label; the text stays the tab’s accessible name.

Tabs with icons

[Complete TSX file](/examples/tabs/icon-tabs.tsx)

```tsx
"use client";

import { FolderIcon, HouseIcon, SettingsIcon } from "lucide-react";
import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

export default function Example() {
  return (
    <Tabs defaultValue="overview">
      <TabsList aria-label="Workspace sections">
        <TabsTab value="overview">
          <HouseIcon aria-hidden="true" />
          Overview
        </TabsTab>
        <TabsTab value="projects">
          <FolderIcon aria-hidden="true" />
          Projects
        </TabsTab>
        <TabsTab value="settings">
          <SettingsIcon aria-hidden="true" />
          Settings
        </TabsTab>
      </TabsList>
      <TabsPanel value="overview">
        <p className="text-muted-foreground text-sm">
          Recent activity across your workspace.
        </p>
      </TabsPanel>
      <TabsPanel value="projects">
        <p className="text-muted-foreground text-sm">
          All projects you can view or edit.
        </p>
      </TabsPanel>
      <TabsPanel value="settings">
        <p className="text-muted-foreground text-sm">
          Workspace name, members and billing.
        </p>
      </TabsPanel>
    </Tabs>
  );
}
```

## Vertical orientation

Set orientation on Tabs so layout and keyboard direction agree. Vertical tabs fill the width of the list, so their labels start at one edge.

Vertical settings tabs

[Complete TSX file](/examples/tabs/vertical.tsx)

```tsx
"use client";

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

export default function Example() {
  return (
    <Tabs
      className="w-full max-w-md"
      defaultValue="profile"
      orientation="vertical"
    >
      <TabsList aria-label="Account settings">
        <TabsTab value="profile">Profile</TabsTab>
        <TabsTab value="password">Password</TabsTab>
        <TabsTab value="notifications">Notifications</TabsTab>
      </TabsList>
      <TabsPanel value="profile">
        <p className="text-muted-foreground text-sm">
          Update your name, photo and contact email.
        </p>
      </TabsPanel>
      <TabsPanel value="password">
        <p className="text-muted-foreground text-sm">
          Change your password and sign out of other sessions.
        </p>
      </TabsPanel>
      <TabsPanel value="notifications">
        <p className="text-muted-foreground text-sm">
          Choose which updates reach you by email.
        </p>
      </TabsPanel>
    </Tabs>
  );
}
```

## Sizes

Root size flows through List to Tab; a closer explicit size overrides it.

Tab sizes

[Complete TSX file](/examples/tabs/sizes.tsx)

```tsx
"use client";

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

export default function Example() {
  return (
    <div className="grid gap-5">
      {(["sm", "md", "lg", "xl"] as const).map((size) => (
        <Tabs key={size} size={size} defaultValue="overview">
          <TabsList aria-label="Project sections">
            <TabsTab value="overview">Overview</TabsTab>
            <TabsTab value="activity">Activity</TabsTab>
            <TabsTab value="billing" disabled>
              Billing
            </TabsTab>
          </TabsList>
          <TabsPanel value="overview">
            <p className="text-muted-foreground text-sm">
              Key figures for this project.
            </p>
          </TabsPanel>
          <TabsPanel value="activity">
            <p className="text-muted-foreground text-sm">No recent activity.</p>
          </TabsPanel>
        </Tabs>
      ))}
    </div>
  );
}
```

## Controlled selection

Match each tab value to its panel. The application owns controlled selection.

Controlled selection

[Complete TSX file](/examples/tabs/controlled.tsx)

```tsx
"use client";

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";
import { useState } from "react";

export default function Example() {
  const [value, setValue] = useState("overview");
  return (
    <Tabs value={value} onValueChange={setValue}>
      <TabsList aria-label="Project sections">
        <TabsTab value="overview">Overview</TabsTab>
        <TabsTab value="activity">Activity</TabsTab>
        <TabsTab value="billing" disabled>
          Billing
        </TabsTab>
      </TabsList>
      <TabsPanel value="overview">
        <p className="text-muted-foreground text-sm">
          Key figures for this project.
        </p>
      </TabsPanel>
      <TabsPanel value="activity">
        <p className="text-muted-foreground text-sm">No recent activity.</p>
      </TabsPanel>
    </Tabs>
  );
}
```

## More tabs than fit

When the tabs do not fit, the list scrolls, and a scroll button holds its place at each edge. Each button shows while more tabs wait in its direction and moves the list by a page of tabs. The selected tab scrolls into view when the list first overflows and whenever the selection changes, and the arrow keys bring the focused tab into view. The buttons are for a mouse or trackpad. Keyboard users move through the tabs with the arrow keys, so the buttons stay out of the tab order and the accessibility tree. On a touch screen the list scrolls by swiping, so the buttons stay hidden and take no room. Vertical lists and lists inside a `TabsRail` keep their own scrolling. There is nothing to set up.

Nine settings tabs in a narrow column

[Complete TSX file](/examples/tabs/overflow.tsx)

```tsx
"use client";

import {
  Tabs,
  TabsList,
  TabsPanel,
  TabsTab,
} from "@/siteplane/components/tabs";

const sections = [
  { value: "overview", label: "Overview" },
  { value: "members", label: "Members and roles" },
  { value: "billing", label: "Billing" },
  { value: "invoices", label: "Invoices" },
  { value: "integrations", label: "Integrations" },
  { value: "api", label: "API keys and webhooks" },
  { value: "audit", label: "Audit log" },
  { value: "security", label: "Security" },
  { value: "export", label: "Data export" },
];

export default function Example() {
  return (
    <Tabs className="w-full max-w-md" defaultValue="api">
      <TabsList aria-label="Workspace settings">
        {sections.map((section) => (
          <TabsTab key={section.value} value={section.value}>
            {section.label}
          </TabsTab>
        ))}
      </TabsList>
      {sections.map((section) => (
        <TabsPanel key={section.value} value={section.value}>
          <p className="pt-2 text-muted-foreground text-sm">
            {section.label} of the Northlight workspace.
          </p>
        </TabsPanel>
      ))}
    </Tabs>
  );
}
```

## Switching panels

Switching tabs shows the new panel at once, as tabs do in the WAI-ARIA pattern and most interfaces. The indicator glides to the new tab, and the element that holds the panels follows the new panel's height over `--motion-duration-expand` (200 ms), so the content below never jumps. No moment shows two panels or none. There is nothing to set up. A panel with `keepMounted` keeps its state through the switch, and reduced motion changes the height at once.

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| Tabs.size | sm \| md \| lg \| xl | provider / lg | Shared size. |
| Tabs.value / defaultValue | unknown | Base UI default | Controlled or initial tab. |
| Tabs.onValueChange | (value, eventDetails) => void | unset | Selection change. |
| TabsList.variant | TabsVariant: default \| pill \| underline | default | List and tab treatment. |
| TabsList.divider | boolean | false | Underline only: a hairline under all tabs that the active underline sits on. |
| TabsList.embedded | boolean | false | Suppress own indicator when inside a rail. |
| TabsList.size / TabsTab.size | sm \| md \| lg \| xl | nearest context / provider | Override size. |
| TabsRail.variant | default \| pill | default | Shared rail treatment. |
| TabsRail.activeIndicatorRef | RefObject<HTMLElement \| null> | selected tab in rail | Custom active indicator target. |
| TabsTab.render | ReactElement \| render function | Siteplane UI Button (text) | Composed tab trigger. |
| TabsPanel.keepMounted | boolean | false | Preserve hidden panel state. |
`TabsProps`, `TabsListProps`, `TabsTabProps` and `TabsVariant` are exported.
TabsTrigger aliases TabsTab; TabsContent aliases TabsPanel; TabsPrimitive
exports the Base UI namespace. Tab/Panel value, disabled, orientation,
activation, refs and focus follow [Base UI
Tabs](https://base-ui.com/react/components/tabs). Styled classes are strings.
TabsRail accepts native div props and ref and measures its indicator on
selection, resize and scroll; it adds no selection behavior. The indicator is
clipped to the strip that shows the tabs: the list, or the scroll viewport of a
ScrollArea that holds the list inside the rail. Before the page hydrates, the
active tab draws the selection itself, and the indicator takes over in place
once it has measured the tab, so the selection shows from the first paint
without an inline script. TabsList renders its list in a frame with
`data-slot="tabs-list-frame"`, which adds no box while the tabs fit or on a
touch screen and holds the two `tabs-scroll-button` parts beside the list when
they overflow; the list itself keeps `className` and the other props. The scroll
takes `--motion-duration-moderate` and `--motion-ease-standard`, and reduced
motion makes it instant.

[Read the installed source](/source/tabs).

## Usage notes

Tab enters the selected tab; arrow keys move focus among enabled tabs, and Enter or Space selects the focused tab. Set `activateOnFocus` on TabsList to select tabs as they receive focus. Hidden panels unmount unless `keepMounted` is set, so keep costly state outside the panel or preserve it with `keepMounted`. The panel that leaves stays mounted below the new one until it has faded out. While the panels swap, the element that holds them is positioned and clipped; a container that scrolls keeps its own height.
