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 filesImport the component into your page or component file.
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
"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
"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
"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
"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
"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
"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.
Draft
The launch plan is still editable by contributors.
Review
Approvers can compare copy, rollout, and risks.
Publish
Final checks are ready for the release owner.
Show sourceexamples/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.
Disabled tabs stay visible but are skipped by keyboard navigation.
Show sourceexamples/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
"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.
| Prop | What it does | Default |
|---|---|---|
valuestring | Controlled selected tab value. | Not set |
defaultValuestring | Initial selected value for uncontrolled usage. | first enabled trigger |
onValueChange(value: string) => void | Fires 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" |
collapsibleboolean | Collapses each trigger to its icon, revealing the label only for the selected tab and on hover/focus. Requires an icon per trigger. | false |
loopboolean | Allows arrow navigation to wrap from the last tab to first. | true |
disabledboolean | Disables all triggers in the tab set. | false |
| Prop | What it does | Default |
|---|---|---|
Trigger valuestring | Stable identity shared by one trigger and one panel. | Not set |
Trigger iconReactNode | Leading icon rendered beside the label; stays visible when the label collapses. | Not set |
Trigger disabledboolean | Disables one trigger and removes it from roving keyboard navigation. | false |
Panel valuestring | Matches the trigger value that controls this panel. | Not set |
Panel forceMountboolean | Keeps inactive panel DOM mounted while hidden. | false |