Tabs
FreeSwitch between related views of the same content without leaving the page.
pnpm dlx shadcn@latest add https://caliperui.site/r/tabs.jsonOctober so far
- Collected
- $48,210
- Outstanding
- $13,878
- Failed
- 1
Next payout of $31,402.18 lands on 14 Oct.
When to use #
Use it for
- Overview, activity and settings views of one record.
- Two to six sibling views.
Skip it when
- The views are steps; use a stepper.
- Each view is a separate page; use navigation links.
Decisions #
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- The panel swaps at once
- Only the underline travels, 320ms on the settle curve; the content changes immediately so nobody waits for a transition to read.
Styling
- Labels never change weight
- A selected tab is marked by the line and colour, not bold text, so the row of labels never shifts width.
Usage
- Counts sit inside the tab
- Use TabsCount for numbers so they read with the label and stay inside its hit area.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/tabs.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above. It is built from Tabs, TabsList, TabsTrigger, TabsContent and TabsCount.
import { Upload } from "lucide-react" import { useState } from "react" import { Tabs, TabsContent, TabsList, TabsTrigger, TabsCount } from "@/components/ui/tabs" export function ProjectTabs() { const [tab, setTab] = useState("overview"); const panels: Record<string, { title: string; stats: [string, string][]; note: string }> = { overview: { title: "October so far", stats: [["Collected", "$48,210"], ["Outstanding", "$13,878"], ["Failed", "1"]], note: "Next payout of $31,402.18 lands on 14 Oct." }, invoices: { title: "128 invoices this year", stats: [["Paid", "119"], ["Due", "8"], ["Failed", "1"]], note: "INV-0416 for Ostra Freight is due 14 Oct." }, customers: { title: "42 customers", stats: [["Active", "39"], ["Churned", "3"], ["Avg. invoice", "$2,140"]], note: "Halden Analytics is your largest account." }, disputes: { title: "2 open disputes", stats: [["Amount", "$1,836.00"], ["Respond by", "21 Oct"], ["Won this year", "4 of 5"]], note: "Upload evidence before the deadline or the charge is reversed." }, }; return ( <Tabs value={tab} onValueChange={setTab} className="w-full max-w-xl"> <TabsList aria-label="Billing"> <TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="invoices">Invoices <TabsCount>128</TabsCount></TabsTrigger> <TabsTrigger value="customers">Customers <TabsCount>42</TabsCount></TabsTrigger> <TabsTrigger value="disputes">Disputes <TabsCount>2</TabsCount></TabsTrigger> <TabsTrigger value="tax" disabled>Tax</TabsTrigger> </TabsList> {Object.entries(panels).map(([key, panel]) => ( <TabsContent key={key} value={key} className="grid gap-3"> <p className="text-base font-semibold">{panel.title}</p> <dl className="grid grid-cols-3 gap-4"> {panel.stats.map(([label, value]) => ( <div key={label} className="grid gap-0.5"> <dt className="text-xs text-muted-foreground">{label}</dt> <dd className="m-0 text-2xl font-medium tracking-tight tabular-nums">{value}</dd> </div> ))} </dl> <p className="text-sm text-muted-foreground">{panel.note}</p> </TabsContent> ))} </Tabs> ); }
API reference #
Tabs
component
No props of its own; it accepts the native props of its root element.
TabsList
component
Also accepts the native props of its root element and a ref.
TabsTrigger
component
No props of its own; it accepts the native props of its root element.
TabsContent
component
No props of its own; it accepts the native props of its root element.
TabsCount
component
No props of its own; it accepts the native props of its root element.
Accessibility #
It exposes tab semantics, with aria-hidden kept in step with state.
Keyboard and focus handling comes from Radix tabs: arrow keys move through items, Enter and Space select, and Escape closes and returns focus.
Motion #
It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.