Siteplane UI

Tabs

Switch between associated content panels.

Markdown
Overview
Key figures and recent activity for this project.

12 open tasks, 3 due this week.

"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

pnpm dlx shadcn@4.18.0 add @siteplane/tabs

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/card

Usage

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

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 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.

Key figures for this project.

Key figures for this project.

Key figures for this project.

"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.

Your name, photo and contact email.

"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.

Recent activity across your workspace.

"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.

Update your name, photo and contact email.

"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.

Key figures for this project.

Key figures for this project.

Key figures for this project.

Key figures for this project.

"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.

Key figures for this project.

"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.

API keys and webhooks of the Northlight workspace.

"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

Component properties
PropTypeDefaultDescription
Tabs.sizesm | md | lg | xlprovider / lgShared size.
Tabs.value / defaultValueunknownBase UI defaultControlled or initial tab.
Tabs.onValueChange(value, eventDetails) => voidunsetSelection change.
TabsList.variantTabsVariant: default | pill | underlinedefaultList and tab treatment.
TabsList.dividerbooleanfalseUnderline only: a hairline under all tabs that the active underline sits on.
TabsList.embeddedbooleanfalseSuppress own indicator when inside a rail.
TabsList.size / TabsTab.sizesm | md | lg | xlnearest context / providerOverride size.
TabsRail.variantdefault | pilldefaultShared rail treatment.
TabsRail.activeIndicatorRefRefObject<HTMLElement | null>selected tab in railCustom active indicator target.
TabsTab.renderReactElement | render functionSiteplane UI Button (text)Composed tab trigger.
TabsPanel.keepMountedbooleanfalsePreserve 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. 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.

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.