Skip to content
Dethink Components

ComponentsTabs

Tabs

Switch between related views without leaving the page.

On this page

Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.

View registry files

Import the component into your page or component file.

Usage
import { Tabs } from "@dethink/components";

Use Tab to enter the tablist. Arrow keys move through enabled tabs; automatic mode selects on focus, while manual mode uses Enter or Space.

Pill tabs

The selected background is a decorative Motion shared-layout layer that travels between triggers.

Workspace overview

Track owner, plan, and operational readiness from one compact surface.

Show sourceexamples/tabs/basic.tsx
examples/tabs/basic.tsx
"use client";

import { Tabs } from "@dethink/components";

const tabs = [
  {
    value: "overview",
    label: "Overview",
    title: "Workspace overview",
    body: "Track owner, plan, and operational readiness from one compact surface.",
  },
  {
    value: "members",
    label: "Members",
    title: "Members",
    body: "Review admins, pending invites, and role coverage before handing off access.",
  },
  {
    value: "billing",
    label: "Billing",
    title: "Billing",
    body: "Keep billing status, invoices, and plan limits close to workspace settings.",
  },
  {
    value: "security",
    label: "Security",
    title: "Security",
    body: "Confirm SSO, audit retention, and recovery controls before rollout.",
  },
  {
    value: "automation",
    label: "Automation",
    title: "Automation",
    body: "Move from Tab 1 to Tab 5 to see the active background glide across tabs.",
  },
];

export function TabsBasic() {
  return (
    <Tabs defaultValue="overview" className="max-w-3xl">
      <Tabs.List aria-label="Workspace sections">
        {tabs.map((tab) => (
          <Tabs.Trigger key={tab.value} value={tab.value}>
            {tab.label}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      {tabs.map((tab) => (
        <Tabs.Panel key={tab.value} value={tab.value}>
          <div className="border-border bg-background mt-2 rounded-lg border p-5">
            <h3 className="text-foreground text-base font-semibold">
              {tab.title}
            </h3>
            <p className="text-muted-foreground mt-2 max-w-2xl text-sm leading-6">
              {tab.body}
            </p>
          </div>
        </Tabs.Panel>
      ))}
    </Tabs>
  );
}

Line tabs

Use the quieter line variant for report views and dense panels.

Summary

Revenue, retention, and usage movement.

Show sourceexamples/tabs/line.tsx
examples/tabs/line.tsx
"use client";

import { Tabs } from "@dethink/components";

const reports = [
  ["summary", "Summary", "Revenue, retention, and usage movement."],
  ["traffic", "Traffic", "Acquisition sources and product entry points."],
  ["conversion", "Conversion", "Trial, activation, and expansion funnels."],
];

export function TabsLine() {
  return (
    <Tabs defaultValue="summary" variant="line" className="max-w-2xl">
      <Tabs.List aria-label="Report views">
        {reports.map(([value, label]) => (
          <Tabs.Trigger key={value} value={value}>
            {label}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      {reports.map(([value, label, body]) => (
        <Tabs.Panel key={value} value={value} className="pt-4">
          <h3 className="text-foreground text-sm font-semibold">{label}</h3>
          <p className="text-muted-foreground mt-2 text-sm leading-6">{body}</p>
        </Tabs.Panel>
      ))}
    </Tabs>
  );
}

Icon tabs

Leading icons and count badges stay legible while the active pill glides beneath them; press a trigger to feel the tactile scale.

Live activity

Deploys, incidents, and audit events stream into one timeline.

Show sourceexamples/tabs/icons.tsx
examples/tabs/icons.tsx
"use client";

import {
  Activity,
  Bell,
  CreditCard,
  Users,
  type LucideIcon,
} from "lucide-react";
import { Tabs } from "@dethink/components";

type IconTab = {
  value: string;
  label: string;
  icon: LucideIcon;
  count?: number;
  title: string;
  body: string;
};

const tabs: IconTab[] = [
  {
    value: "activity",
    label: "Activity",
    icon: Activity,
    title: "Live activity",
    body: "Deploys, incidents, and audit events stream into one timeline.",
  },
  {
    value: "members",
    label: "Members",
    icon: Users,
    count: 12,
    title: "Members",
    body: "Twelve teammates share this workspace across three roles.",
  },
  {
    value: "billing",
    label: "Billing",
    icon: CreditCard,
    title: "Billing",
    body: "The Scale plan renews on the first with usage-based overages.",
  },
  {
    value: "alerts",
    label: "Alerts",
    icon: Bell,
    count: 3,
    title: "Alerts",
    body: "Three alert rules are watching latency, error rate, and spend.",
  },
];

export function TabsIcons() {
  return (
    <Tabs defaultValue="activity" size="lg" className="max-w-3xl">
      <Tabs.List aria-label="Workspace areas">
        {tabs.map((tab) => {
          const Icon = tab.icon;

          return (
            <Tabs.Trigger
              key={tab.value}
              value={tab.value}
              icon={<Icon aria-hidden />}
            >
              {tab.label}
              {tab.count ? (
                <span className="bg-muted text-muted-foreground group-data-[selected=true]:bg-primary-foreground/20 group-data-[selected=true]:text-primary-foreground inline-flex min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold tabular-nums">
                  {tab.count}
                </span>
              ) : null}
            </Tabs.Trigger>
          );
        })}
      </Tabs.List>
      {tabs.map((tab) => (
        <Tabs.Panel key={tab.value} value={tab.value}>
          <div className="border-border bg-background mt-2 rounded-lg border p-5">
            <h3 className="text-foreground text-base font-semibold">
              {tab.title}
            </h3>
            <p className="text-muted-foreground mt-2 text-sm leading-6">
              {tab.body}
            </p>
          </div>
        </Tabs.Panel>
      ))}
    </Tabs>
  );
}

Collapsible icon rail

Pass collapsible plus an icon on each trigger to keep only the active tab labeled. Hover or focus a collapsed tab to reveal its name; selecting it expands the label while the previous tab settles back to an icon. The same reveal works in vertical layout as an expandable side rail.

Horizontal

Explore

Only the active tab keeps its label. Hover any collapsed tab to reveal its name, then click to expand it.

Vertical

Explore

Only the active tab keeps its label. Hover any collapsed tab to reveal its name, then click to expand it.

Show sourceexamples/tabs/collapsible.tsx
examples/tabs/collapsible.tsx
"use client";

import {
  Compass,
  Inbox,
  PieChart,
  Settings,
  type LucideIcon,
} from "lucide-react";
import { Tabs } from "@dethink/components";

type RailTab = {
  value: string;
  label: string;
  icon: LucideIcon;
  title: string;
  body: string;
};

const tabs: RailTab[] = [
  {
    value: "explore",
    label: "Explore",
    icon: Compass,
    title: "Explore",
    body: "Only the active tab keeps its label. Hover any collapsed tab to reveal its name, then click to expand it.",
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: Inbox,
    title: "Inbox",
    body: "Selecting a tab expands it and lets the previously active tab settle back to an icon.",
  },
  {
    value: "reports",
    label: "Reports",
    icon: PieChart,
    title: "Reports",
    body: "The active pill glides between tabs while each label expands or collapses along the same motion.",
  },
  {
    value: "settings",
    label: "Settings",
    icon: Settings,
    title: "Settings",
    body: "Labels stay in the DOM while collapsed, so each tab keeps an accessible name for screen readers.",
  },
];

export function TabsCollapsible() {
  return (
    <div className="grid gap-10 lg:grid-cols-2">
      <div className="grid content-start gap-3">
        <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
          Horizontal
        </p>
        <Tabs collapsible defaultValue="explore">
          <Tabs.List aria-label="Workspace rail">
            {tabs.map((tab) => {
              const Icon = tab.icon;

              return (
                <Tabs.Trigger
                  key={tab.value}
                  value={tab.value}
                  icon={<Icon aria-hidden />}
                >
                  {tab.label}
                </Tabs.Trigger>
              );
            })}
          </Tabs.List>
          {tabs.map((tab) => (
            <Tabs.Panel key={tab.value} value={tab.value}>
              <div className="border-border bg-background mt-2 rounded-lg border p-4">
                <h4 className="text-foreground text-sm font-semibold">
                  {tab.title}
                </h4>
                <p className="text-muted-foreground mt-1 text-sm leading-6">
                  {tab.body}
                </p>
              </div>
            </Tabs.Panel>
          ))}
        </Tabs>
      </div>

      <div className="grid content-start gap-3">
        <p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
          Vertical
        </p>
        <Tabs
          collapsible
          orientation="vertical"
          defaultValue="explore"
          className="grid grid-flow-col items-start justify-start gap-5"
        >
          <Tabs.List aria-label="Workspace rail (vertical)">
            {tabs.map((tab) => {
              const Icon = tab.icon;

              return (
                <Tabs.Trigger
                  key={tab.value}
                  value={tab.value}
                  icon={<Icon aria-hidden />}
                >
                  {tab.label}
                </Tabs.Trigger>
              );
            })}
          </Tabs.List>
          <div className="min-w-0">
            {tabs.map((tab) => (
              <Tabs.Panel key={tab.value} value={tab.value}>
                <div className="border-border bg-background rounded-lg border p-4">
                  <h4 className="text-foreground text-sm font-semibold">
                    {tab.title}
                  </h4>
                  <p className="text-muted-foreground mt-1 text-sm leading-6">
                    {tab.body}
                  </p>
                </div>
              </Tabs.Panel>
            ))}
          </div>
        </Tabs>
      </div>
    </div>
  );
}

Motion presets

Switch tabs in each column: the active layer glides and the revealed panel body fades and lifts along the same axis. Compare subtle, standard, and expressive.

Subtle

Short, near-linear glide. Best for dense, utilitarian surfaces.

Plan

Draft scope, owners, and the rollout window.

Standard

The default. A little spring on the active layer, calm content lift.

Plan

Draft scope, owners, and the rollout window.

Expressive

More bounce and travel for marketing or onboarding moments.

Plan

Draft scope, owners, and the rollout window.

Show sourceexamples/tabs/motion.tsx
examples/tabs/motion.tsx
"use client";

import { Tabs, type TabsMotionPreset } from "@dethink/components";

const presets: { preset: TabsMotionPreset; label: string; note: string }[] = [
  {
    preset: "subtle",
    label: "Subtle",
    note: "Short, near-linear glide. Best for dense, utilitarian surfaces.",
  },
  {
    preset: "standard",
    label: "Standard",
    note: "The default. A little spring on the active layer, calm content lift.",
  },
  {
    preset: "expressive",
    label: "Expressive",
    note: "More bounce and travel for marketing or onboarding moments.",
  },
];

const steps = [
  ["plan", "Plan", "Draft scope, owners, and the rollout window."],
  ["build", "Build", "Wire the vertical slice with tests and docs together."],
  ["ship", "Ship", "Flip the flag, watch the dashboards, and announce."],
];

export function TabsMotion() {
  return (
    <div className="grid gap-8 md:grid-cols-3">
      {presets.map(({ preset, label, note }) => (
        <div key={preset} className="grid content-start gap-3">
          <div>
            <p className="text-foreground text-sm font-semibold">{label}</p>
            <p className="text-muted-foreground mt-1 text-xs leading-5">
              {note}
            </p>
          </div>
          <Tabs defaultValue="plan" motionPreset={preset} size="sm">
            <Tabs.List aria-label={`${label} motion example`}>
              {steps.map(([value, stepLabel]) => (
                <Tabs.Trigger key={value} value={value}>
                  {stepLabel}
                </Tabs.Trigger>
              ))}
            </Tabs.List>
            {steps.map(([value, stepLabel, body]) => (
              <Tabs.Panel key={value} value={value}>
                <div className="border-border bg-background mt-1 rounded-lg border p-4">
                  <h4 className="text-foreground text-sm font-semibold">
                    {stepLabel}
                  </h4>
                  <p className="text-muted-foreground mt-1 text-sm leading-6">
                    {body}
                  </p>
                </div>
              </Tabs.Panel>
            ))}
          </Tabs>
        </div>
      ))}
    </div>
  );
}

Vertical

Vertical orientation uses Up and Down arrow keys and exposes aria-orientation.

Profile

Name, title, avatar, and public contact details.

Show sourceexamples/tabs/vertical.tsx
examples/tabs/vertical.tsx
"use client";

import { Tabs } from "@dethink/components";

const sections = [
  ["profile", "Profile", "Name, title, avatar, and public contact details."],
  ["access", "Access", "Workspace roles, groups, and invite controls."],
  ["notifications", "Notifications", "Email, Slack, and digest preferences."],
];

export function TabsVertical() {
  return (
    <Tabs
      defaultValue="profile"
      orientation="vertical"
      variant="line"
      className="grid gap-5 md:grid-cols-[12rem_minmax(0,1fr)]"
    >
      <Tabs.List aria-label="Profile settings">
        {sections.map(([value, label]) => (
          <Tabs.Trigger key={value} value={value}>
            {label}
          </Tabs.Trigger>
        ))}
      </Tabs.List>
      <div className="min-w-0">
        {sections.map(([value, label, body]) => (
          <Tabs.Panel key={value} value={value}>
            <div className="border-border rounded-lg border p-5">
              <h3 className="text-foreground text-base font-semibold">
                {label}
              </h3>
              <p className="text-muted-foreground mt-2 text-sm leading-6">
                {body}
              </p>
            </div>
          </Tabs.Panel>
        ))}
      </div>
    </Tabs>
  );
}

Controlled

Drive selected value from app state and force-mount panels when local content state must persist.

Review

Approvers can compare copy, rollout, and risks.

Show sourceexamples/tabs/controlled.tsx
examples/tabs/controlled.tsx
"use client";

import { useState } from "react";
import { Tabs, type TabsValue } from "@dethink/components";

const steps = [
  ["draft", "Draft", "The launch plan is still editable by contributors."],
  ["review", "Review", "Approvers can compare copy, rollout, and risks."],
  ["publish", "Publish", "Final checks are ready for the release owner."],
];

export function TabsControlled() {
  const [value, setValue] = useState<TabsValue>("review");

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap gap-2">
        {steps.map(([nextValue, label]) => (
          <button
            key={nextValue}
            type="button"
            data-active={value === nextValue ? "true" : undefined}
            className="border-border text-muted-foreground data-[active=true]:bg-muted data-[active=true]:text-foreground rounded-md border px-3 py-1 text-sm"
            onClick={() => setValue(nextValue)}
          >
            {label}
          </button>
        ))}
      </div>
      <Tabs value={value} onValueChange={setValue} className="max-w-2xl">
        <Tabs.List aria-label="Launch steps">
          {steps.map(([nextValue, label]) => (
            <Tabs.Trigger key={nextValue} value={nextValue}>
              {label}
            </Tabs.Trigger>
          ))}
        </Tabs.List>
        {steps.map(([nextValue, label, body]) => (
          <Tabs.Panel key={nextValue} forceMount value={nextValue}>
            <div className="border-border rounded-lg border p-5">
              <h3 className="text-foreground text-sm font-semibold">{label}</h3>
              <p className="text-muted-foreground mt-2 text-sm leading-6">
                {body}
              </p>
            </div>
          </Tabs.Panel>
        ))}
      </Tabs>
    </div>
  );
}

States

Disabled triggers are skipped, manual activation waits for Enter or Space, and motionPreset none renders a static layer.

The active layer is static here because motionPreset is none.

Show sourceexamples/tabs/states.tsx
examples/tabs/states.tsx
"use client";

import { Tabs } from "@dethink/components";

export function TabsStates() {
  return (
    <Tabs
      activationMode="manual"
      defaultValue="active"
      motionPreset="none"
      className="max-w-2xl"
    >
      <Tabs.List aria-label="State examples">
        <Tabs.Trigger value="active">Active</Tabs.Trigger>
        <Tabs.Trigger value="disabled" disabled>
          Disabled
        </Tabs.Trigger>
        <Tabs.Trigger value="manual">Manual activation</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Panel value="active">
        <p className="text-muted-foreground text-sm leading-6">
          The active layer is static here because motionPreset is none.
        </p>
      </Tabs.Panel>
      <Tabs.Panel forceMount value="disabled">
        <p className="text-muted-foreground text-sm leading-6">
          Disabled tabs stay visible but are skipped by keyboard navigation.
        </p>
      </Tabs.Panel>
      <Tabs.Panel value="manual">
        <p className="text-muted-foreground text-sm leading-6">
          Arrow keys move focus; Enter or Space activates the focused tab.
        </p>
      </Tabs.Panel>
    </Tabs>
  );
}

Production-shaped compositions for settings and internal-tool pages.

Settings page

Tabs compose with Button and card-like content without owning form state or persistence.

General

Workspace name, default region, and owner details.

Status

Ready for review

Owner

Platform operations

Show sourceexamples/tabs/recipe-settings.tsx
examples/tabs/recipe-settings.tsx
"use client";

import { Button, Tabs } from "@dethink/components";

const settings = [
  ["general", "General", "Workspace name, default region, and owner details."],
  ["billing", "Billing", "Plan, seats, invoices, and spend controls."],
  ["security", "Security", "SSO, audit logs, recovery, and session policy."],
  ["models", "AI models", "Default model routing and fallback providers."],
];

export function TabsRecipeSettings() {
  return (
    <Tabs defaultValue="general" className="max-w-4xl">
      <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
        <Tabs.List aria-label="Workspace settings sections">
          {settings.map(([value, label]) => (
            <Tabs.Trigger key={value} value={value}>
              {label}
            </Tabs.Trigger>
          ))}
        </Tabs.List>
        <Button size="sm" variant="outline">
          Save changes
        </Button>
      </div>
      {settings.map(([value, label, body]) => (
        <Tabs.Panel key={value} value={value}>
          <div className="border-border bg-background mt-2 grid gap-4 rounded-lg border p-5">
            <div>
              <h3 className="text-foreground text-base font-semibold">
                {label}
              </h3>
              <p className="text-muted-foreground mt-1 text-sm leading-6">
                {body}
              </p>
            </div>
            <div className="grid gap-3 sm:grid-cols-2">
              <div className="bg-muted/35 rounded-md p-3">
                <p className="text-sm font-medium">Status</p>
                <p className="text-muted-foreground mt-1 text-sm">
                  Ready for review
                </p>
              </div>
              <div className="bg-muted/35 rounded-md p-3">
                <p className="text-sm font-medium">Owner</p>
                <p className="text-muted-foreground mt-1 text-sm">
                  Platform operations
                </p>
              </div>
            </div>
          </div>
        </Tabs.Panel>
      ))}
    </Tabs>
  );
}

Keep the visual active layer separate from the accessibility contract.

Semantics

Triggers expose role tab, aria-selected, aria-controls, and roving tabindex. Panels expose role tabpanel and aria-labelledby.

Motion

The active layer is aria-hidden and decorative, and the revealed panel body fades and lifts along the tab axis. Reduced motion or motionPreset none keeps both static.

Activation

Automatic activation is best for instant panels. Manual activation is available when panel content is heavier.

Scope

Tabs v1 is for in-page panels. Use navigation components for route-backed links.

Tabs renders a div root, native button triggers, and tabpanel regions. Every part accepts className and exposes stable data-slot/state hooks.

Tabs props
PropWhat it doesDefault
valuestringControlled selected tab value.Not set
defaultValuestringInitial selected value for uncontrolled usage.first enabled trigger
onValueChange(value: string) => voidFires when a different enabled tab becomes selected.Not set
orientation"horizontal" | "vertical"Sets tablist orientation and arrow-key behavior."horizontal"
activationMode"automatic" | "manual"Automatic selects on focus. Manual requires Enter, Space, or click."automatic"
variant"pill" | "line"Visual style for the active layer and tab list."pill"
size"sm" | "md" | "lg"Adjusts trigger height, padding, and text size."md"
motionPreset"none" | "subtle" | "standard" | "expressive"Controls the shared-layout active layer; reduced motion forces a static layer."standard"
collapsiblebooleanCollapses each trigger to its icon, revealing the label only for the selected tab and on hover/focus. Requires an icon per trigger.false
loopbooleanAllows arrow navigation to wrap from the last tab to first.true
disabledbooleanDisables all triggers in the tab set.false
Part props
PropWhat it doesDefault
Trigger valuestringStable identity shared by one trigger and one panel.Not set
Trigger iconReactNodeLeading icon rendered beside the label; stays visible when the label collapses.Not set
Trigger disabledbooleanDisables one trigger and removes it from roving keyboard navigation.false
Panel valuestringMatches the trigger value that controls this panel.Not set
Panel forceMountbooleanKeeps inactive panel DOM mounted while hidden.false