Tabs
Views of one region, switched by a row of tabs with a sliding pill.
Vibra's list slides one card-plane pill between the tabs — Base UI's Tabs.Indicator, placed by the primitive's own script before hydration so it is under the right tab on first paint — where shadcn fades a background in on each tab in turn; in a vertical list the pill takes the active tab's height rather than the column's. Triggers are set in the label register in muted ink until active, and name no physical side: the tighter padding beside an icon and a vertical line's rail follow the inline axis, so a right-to-left row mirrors whole. The line variant underlines the active tab in the accent (--brand) rather than in ink. The open panel is a tab stop, and wears the kit's focus ring. For tabs that are pages — one URL each — reach for NavTabs.
Install
npx shadcn@latest add @vibra/tabsNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const TABS = [
{ value: "overview", label: "Overview", body: "1,204 paying workspaces, $48,290 in monthly revenue." },
{ value: "usage", label: "Usage", body: "84,120 of 100,000 events used this cycle — 84%." },
{ value: "billing", label: "Billing", body: "Pro plan, renews on October 1, charged to the card ending 4242." },
]
export default function TabsDemo() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList aria-label="Workspace">
{TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-sm text-muted-foreground">{tab.body}</p>
</TabsContent>
))}
</Tabs>
)
}Line
The line variant draws no track: the accent underline lands on the hairline the row sits on, the quiet row under a record's title.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const SECTIONS = [
{ value: "overview", label: "Overview", body: "Customer since March 2023 · Scale plan · $4,820 a month." },
{ value: "invoices", label: "Invoices", body: "24 invoices, all paid. The next one goes out on October 1." },
{ value: "activity", label: "Activity", body: "Saoirse Byrne changed the billing email 2 hours ago." },
{ value: "notes", label: "Notes", body: "Renewal call booked for October 14 with their finance lead." },
]
// Under a record's title the tabs are a quiet row: the line variant draws no
// track, and the accent underline lands on the hairline the row sits on.
export default function TabsLine() {
return (
<Tabs defaultValue="overview" className="w-full max-w-lg gap-4">
<div className="border-b border-rule">
<TabsList variant="line" aria-label="Harbour Logistics">
{SECTIONS.map((section) => (
<TabsTrigger key={section.value} value={section.value}>
{section.label}
</TabsTrigger>
))}
</TabsList>
</div>
{SECTIONS.map((section) => (
<TabsContent key={section.value} value={section.value} className="rounded-md">
<p className="text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
)
}With icons
Each icon is marked inline-start, so the tab tightens its padding on the side the icon is on.
import { CreditCardIcon, SlidersHorizontalIcon, UsersIcon } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const SECTIONS = [
{ value: "general", label: "General", icon: SlidersHorizontalIcon, body: "Workspace name, time zone and the week's first day." },
{ value: "members", label: "Members", icon: UsersIcon, body: "18 members and 2 pending invitations." },
{ value: "billing", label: "Billing", icon: CreditCardIcon, body: "Scale plan, billed yearly to the card ending 4242." },
]
// The icon leads and the words name the tab. It is marked as the inline-start
// icon, so the tab tightens its padding on that side — the start of the line,
// which is the right-hand side in a right-to-left page.
export default function TabsIcons() {
return (
<Tabs defaultValue="general" className="w-full max-w-lg">
<TabsList aria-label="Workspace settings" className="max-w-full">
{SECTIONS.map(({ value, label, icon: Icon }) => (
<TabsTrigger key={value} value={value}>
<Icon aria-hidden="true" data-icon="inline-start" />
{label}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map((section) => (
<TabsContent key={section.value} value={section.value} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
)
}With counts
A count inside the tab is part of its name — "Open 12" — on a pill that takes the plane the tab doesn't.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const QUEUES = [
{ value: "open", label: "Open", count: 12, body: "12 tickets waiting on the team. The oldest came in 3 hours ago." },
{ value: "pending", label: "Pending", count: 4, body: "4 tickets waiting on a customer's reply." },
{ value: "solved", label: "Solved", count: 128, body: "128 solved this week, 94% within the first day." },
]
// The count sits inside the tab, so it is part of what the tab is called:
// "Open 12". Its pill takes the plane the tab doesn't: the card on the track,
// the track inside the chosen tab's card.
export default function TabsCounts() {
return (
<Tabs defaultValue="open" className="w-full max-w-md">
<TabsList aria-label="Support queues">
{QUEUES.map((queue) => (
<TabsTrigger key={queue.value} value={queue.value}>
{queue.label}{" "}
<span
data-icon="inline-end"
className="min-w-5 rounded-full bg-card px-1.5 text-xs tabular-nums in-data-active:bg-muted"
>
{queue.count}
</span>
</TabsTrigger>
))}
</TabsList>
{QUEUES.map((queue) => (
<TabsContent key={queue.value} value={queue.value} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">{queue.body}</p>
</TabsContent>
))}
</Tabs>
)
}Icon only
Each tab is named by aria-label, and a tooltip repeats the name for the pointer. One provider lets the tooltips hand over at once along the row.
import { CalendarDaysIcon, ChartGanttIcon, KanbanIcon, ListIcon } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const VIEWS = [
{ value: "board", label: "Board", icon: KanbanIcon, body: "Q4 launch as a board: 5 columns, 38 tasks." },
{ value: "list", label: "List", icon: ListIcon, body: "Q4 launch as a list, grouped by assignee." },
{ value: "calendar", label: "Calendar", icon: CalendarDaysIcon, body: "Q4 launch on a calendar: 9 tasks due this week." },
{ value: "timeline", label: "Timeline", icon: ChartGanttIcon, body: "Q4 launch on a timeline, October to December." },
]
// Four views in the space of one word. Each tab is named by aria-label, and
// the tooltip only repeats that name for the pointer. One provider holds the
// row: the first tooltip waits a beat, so a passing pointer flashes nothing,
// and once one is open the next opens at once as the pointer sweeps along.
export default function TabsIconOnly() {
return (
<Tabs defaultValue="board" className="w-full max-w-sm items-start">
<TooltipProvider delay={300}>
<TabsList aria-label="Project view">
{VIEWS.map(({ value, label, icon: Icon }) => (
<Tooltip key={value}>
<TooltipTrigger render={<TabsTrigger value={value} aria-label={label} className="px-2" />}>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</TabsList>
</TooltipProvider>
{VIEWS.map((view) => (
<TabsContent key={view.value} value={view.value} className="w-full rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">{view.body}</p>
</TabsContent>
))}
</Tabs>
)
}Vertical
A column beside the panel, as a settings page lays out: Up and Down move, and the pill slides down the column at the tab's own height.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const SECTIONS = [
{ value: "profile", label: "Profile", body: "Your name, photo and the time zone reports use." },
{ value: "notifications", label: "Notifications", body: "Email me when an invoice is 7 days overdue." },
{ value: "security", label: "Security", body: "Two-step verification is on. Last sign-in from Dublin." },
]
// A column of tabs beside the panel, the way a settings page lays out.
// vertical orientation moves the arrow keys to Up and Down, and the pill
// slides down the column instead of along a row.
export default function TabsVertical() {
return (
<Tabs defaultValue="profile" orientation="vertical" className="w-full max-w-lg gap-4">
<TabsList aria-label="Account settings" className="w-32 shrink-0 items-stretch">
{SECTIONS.map((section) => (
<TabsTrigger key={section.value} value={section.value} className="h-8">
{section.label}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map((section) => (
<TabsContent key={section.value} value={section.value} className="min-w-0 rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
)
}More tabs than room
Eight reports on a phone: the row scrolls inside its own box, so the page never scrolls sideways and the tabs keep their words.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const REPORTS = [
{ value: "overview", label: "Overview", total: "$48,290 MRR" },
{ value: "revenue", label: "Revenue", total: "$579,480 this year" },
{ value: "customers", label: "Customers", total: "1,204 paying" },
{ value: "subscriptions", label: "Subscriptions", total: "1,318 active" },
{ value: "churn", label: "Churn", total: "2.1% last month" },
{ value: "invoices", label: "Invoices", total: "289 this quarter" },
{ value: "payouts", label: "Payouts", total: "$41,760 on Friday" },
{ value: "tax", label: "Tax", total: "$9,114 collected" },
]
// Eight reports never fit a phone. The row scrolls on its own, inside a box
// that keeps its width, so the page never scrolls sideways; the tabs keep
// their full words, and a tab the arrow keys reach is scrolled into view.
export default function TabsScroll() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<div className="-m-1 overflow-x-auto overscroll-x-contain p-1 [scrollbar-width:thin]">
<TabsList aria-label="Reports">
{REPORTS.map((report) => (
<TabsTrigger key={report.value} value={report.value} className="flex-none">
{report.label}
</TabsTrigger>
))}
</TabsList>
</div>
{REPORTS.map((report) => (
<TabsContent key={report.value} value={report.value} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">
{report.label}: <span className="text-foreground tabular-nums">{report.total}</span>
</p>
</TabsContent>
))}
</Tabs>
)
}Disabled, with a reason
A report the plan doesn't include stays in the row, dimmed, and is described by the sentence that says what unlocks it.
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
// Forecasts come with a higher plan. The tab stays in the row, so the reader
// learns the report exists; it is disabled but still reachable by the arrow
// keys, and it is described by the sentence that says why and what unlocks it.
export default function TabsDisabled() {
return (
<Tabs defaultValue="summary" className="w-full max-w-md">
<TabsList aria-label="Revenue reports">
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="cohorts">Cohorts</TabsTrigger>
<TabsTrigger value="forecast" disabled aria-describedby="tabs-disabled-reason">
Forecast{" "}
<Badge variant="outline" data-icon="inline-end" className="h-4 px-1.5">
Scale
</Badge>
</TabsTrigger>
</TabsList>
<p id="tabs-disabled-reason" className="text-xs text-muted-foreground">
Forecasts come with the Scale plan. Ask a workspace owner to upgrade.
</p>
<TabsContent value="summary" className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">$48,290 in monthly revenue, up 6.2% on August.</p>
</TabsContent>
<TabsContent value="cohorts" className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">The March cohort keeps 91% of its revenue after six months.</p>
</TabsContent>
</Tabs>
)
}Opens as you arrow
activateOnFocus shows each panel as the arrow keys land on its tab — for panels that cost nothing to show.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const PLANS = [
{ value: "starter", label: "Starter", price: "$29", seats: "3 seats", history: "90 days of history" },
{ value: "growth", label: "Growth", price: "$99", seats: "10 seats", history: "Two years of history" },
{ value: "scale", label: "Scale", price: "$249", seats: "Unlimited seats", history: "Forecasts and SSO" },
]
// Each panel is already on the page and costs nothing to show, so the arrow
// keys show it as they land (activateOnFocus). Where a panel loads data, keep
// the default: arrows move the focus, and Enter or Space opens the panel.
export default function TabsActivateOnFocus() {
return (
<Tabs defaultValue="growth" className="w-full max-w-sm">
<TabsList aria-label="Plans" activateOnFocus className="w-full">
{PLANS.map((plan) => (
<TabsTrigger key={plan.value} value={plan.value}>
{plan.label}
</TabsTrigger>
))}
</TabsList>
{PLANS.map((plan) => (
<TabsContent key={plan.value} value={plan.value} className="flex flex-col gap-1 rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="type-numeral text-2xl">
{plan.price}
<span className="text-sm font-normal text-muted-foreground"> a month</span>
</p>
<p className="text-muted-foreground">
{plan.seats} · {plan.history}
</p>
</TabsContent>
))}
</Tabs>
)
}Loads its panel
The activity is fetched the first time its tab opens: the panel is busy, says so, and holds the rows' shape until they land.
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
// The activity is fetched the first time its tab opens, and kept. While it
// comes the panel says it is busy and holds the rows' shape; the skeletons
// stand still under reduced motion. Activation stays manual, so arrowing past
// the tab doesn't start a request the reader never asked for.
export default function TabsLoading() {
const [state, setState] = React.useState<"idle" | "loading" | "ready">("idle")
React.useEffect(() => {
if (state !== "loading") return
const timer = window.setTimeout(() => setState("ready"), 900)
return () => window.clearTimeout(timer)
}, [state])
return (
<Tabs
defaultValue="overview"
onValueChange={(value) => value === "activity" && state === "idle" && setState("loading")}
className="w-full max-w-md"
>
<TabsList aria-label="Harbour Logistics">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">Scale plan · 18 seats · renews on October 1.</p>
</TabsContent>
<TabsContent value="activity" aria-busy={state !== "ready"} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p role="status" className="sr-only">
{state === "ready" ? "Activity loaded" : "Loading activity…"}
</p>
{state === "ready" ? (
<ul className="flex flex-col gap-2 text-muted-foreground">
<li>
Invoice <span className="font-mono text-foreground">INV-2041</span> paid, $4,820
</li>
<li>Tomás Ortega added a seat</li>
<li>Plan renewed for a year</li>
</ul>
) : (
<div aria-hidden="true" className="flex flex-col gap-3.5 py-1">
{["w-3/4", "w-1/2", "w-2/3"].map((width) => (
<Skeleton key={width} className={`h-3 ${width}`} />
))}
</div>
)}
</TabsContent>
</Tabs>
)
}A form across tabs
keepMounted keeps both panels' fields in the form. Saving with a field missing opens its tab, marks the tab in words, and focuses the field.
"use client"
import * as React from "react"
import { CircleAlertIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const PANEL = "rounded-lg bg-surface p-4 ring-1 ring-border"
// One form over two tabs. keepMounted keeps both panels in the page, so what
// was typed on one survives a trip to the other and both are sent. A missing
// field can't hide: saving opens the tab that holds it, marks that tab in
// words as well as with the icon, and puts the focus in the field.
export default function TabsForm() {
const [tab, setTab] = React.useState("details")
const [missing, setMissing] = React.useState(false)
const [saved, setSaved] = React.useState("")
const email = React.useRef<HTMLInputElement>(null)
const focusEmail = React.useRef(false)
// A hidden panel can't take the focus, so it waits for the tab to show.
React.useEffect(() => {
if (tab !== "billing" || !focusEmail.current) return
focusEmail.current = false
email.current?.focus()
}, [tab])
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
const ok = String(data.get("email")).trim() !== ""
setMissing(!ok)
setSaved(ok ? `Saved ${data.get("name")}.` : "")
if (ok) return
if (tab === "billing") email.current?.focus()
else {
focusEmail.current = true
setTab("billing")
}
}
return (
<form onSubmit={save} noValidate className="flex w-full max-w-md flex-col gap-3">
<Tabs value={tab} onValueChange={(value) => setTab(String(value))}>
<TabsList aria-label="Customer">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="billing">
Billing
{missing ? <CircleAlertIcon aria-hidden="true" data-icon="inline-end" className="text-danger" /> : null}
{missing ? <span className="sr-only">, has a problem</span> : null}
</TabsTrigger>
</TabsList>
<TabsContent value="details" keepMounted className={PANEL}>
<Field>
<FieldLabel htmlFor="tabs-form-name">Company name</FieldLabel>
<Input id="tabs-form-name" name="name" defaultValue="Harbour Logistics" />
</Field>
</TabsContent>
<TabsContent value="billing" keepMounted className={PANEL}>
<Field>
<FieldLabel htmlFor="tabs-form-email">Billing email</FieldLabel>
<Input
ref={email}
id="tabs-form-email"
name="email"
type="email"
aria-invalid={missing || undefined}
aria-describedby={missing ? "tabs-form-email-error" : undefined}
onChange={(event) => missing && event.target.value && setMissing(false)}
/>
{/* The focus brings the reader here, so the field's description says it once; no alert on top. */}
{missing ? (
<FieldError id="tabs-form-email-error" role={undefined}>
Add the address invoices go to.
</FieldError>
) : null}
</Field>
</TabsContent>
</Tabs>
<div className="flex items-center gap-3">
<Button type="submit">Save customer</Button>
<p role="status" className="text-sm text-muted-foreground">
{saved}
</p>
</div>
</form>
)
}Right to left
The same rows read from the right: the icon's tighter padding, the count and a vertical line's rail all mirror.
import { ActivityIcon, FileTextIcon, LayoutDashboardIcon } from "lucide-react"
import { DirectionProvider } from "@/components/ui/direction"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const SECTIONS = [
{ value: "overview", label: "نظرة عامة", icon: LayoutDashboardIcon, count: null, body: "عميل منذ مارس 2023، على خطة Scale." },
{ value: "invoices", label: "الفواتير", icon: FileTextIcon, count: 24, body: "24 فاتورة، كلها مدفوعة." },
{ value: "activity", label: "النشاط", icon: ActivityIcon, count: 3, body: "3 تغييرات هذا الأسبوع." },
]
const COUNT = "min-w-5 rounded-full bg-card px-1.5 text-xs tabular-nums in-data-active:bg-muted"
// The same rows, read from the right. The icon still leads — at the right —
// and the tighter padding follows it there; the count still trails, at the
// left; a column's rail sits on the side that faces its panel. dir mirrors
// the layout, and DirectionProvider tells Base UI, so the Left arrow moves on
// to the next tab, the way the row reads.
export default function TabsRtl() {
return (
<DirectionProvider direction="rtl">
<div dir="rtl" lang="ar" className="flex w-full max-w-md flex-col gap-6">
<Tabs defaultValue="invoices">
<TabsList aria-label="سجل العميل">
{SECTIONS.map(({ value, label, icon: Icon, count }) => (
<TabsTrigger key={value} value={value}>
<Icon aria-hidden="true" data-icon="inline-start" />
{label}
{count === null ? null : " "}
{count === null ? null : (
<span data-icon="inline-end" className={COUNT}>
{count}
</span>
)}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map((section) => (
<TabsContent key={section.value} value={section.value} className="rounded-lg bg-surface p-4 ring-1 ring-border">
<p className="text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
<Tabs defaultValue="activity" orientation="vertical" className="gap-4">
<TabsList variant="line" aria-label="أقسام السجل" className="w-28 shrink-0 items-stretch">
{SECTIONS.map((section) => (
<TabsTrigger key={section.value} value={section.value}>
{section.label}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map((section) => (
<TabsContent key={section.value} value={section.value} className="min-w-0 rounded-md">
<p className="text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
</div>
</DirectionProvider>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| TabsList.variant | "default" | "line" | "default" | The pill on the muted track, or an accent underline on no track at all. |
| orientation | "horizontal" | "vertical" | "horizontal" | A row of tabs over the panels, or a column beside them. |
| value / defaultValue / onValueChange | unknown / unknown / (value: unknown) => void | — | The open tab, controlled or not — a form that must open the tab holding an error controls it. |
| TabsList.activateOnFocus | boolean | false | Shows a panel as the arrow keys land on its tab. Leave it off when a panel loads data: the arrows then move the focus, and Enter or Space opens the panel. |
| TabsTrigger.disabled | boolean | false | The tab can't be opened. It stays in the row and dims; describe why beside it. |
| TabsContent.keepMounted | boolean | false | Keeps a hidden panel in the page, so a form split over tabs keeps — and submits — what was typed on each. |
Dependencies
Source
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list relative inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
/**
* One pill, moved — not a background fading in on each tab in turn.
*
* The measurement is the primitive's own `Tabs.Indicator`, which writes
* `--active-tab-left` and `--active-tab-width` and re-runs before hydration, so
* the pill is under the right tab on the first paint. SegmentedControl, which
* is hand-rolled, gets the same behaviour from `useSlidingIndicator`; both
* slide over `--duration-base` on the emphasized ease and both draw the sheet:
* the card plane over the recessed track, with the sheet shadow.
*
* In a row the pill is centred in the track at the track's height; in a
* column it takes the active tab's own top and height, or it would stand as
* tall as the whole column. It is placed from the left on purpose: Base UI
* measures `--active-tab-left` from the list's left edge in either direction,
* so a physical left and a physical translate land it under the right tab in
* a right-to-left row too, where an inline-start would not.
*/
function TabsList({
className,
variant = "default",
children,
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
>
{variant === "default" ? (
<TabsPrimitive.Indicator
data-slot="tabs-indicator"
// Base UI's own inline script measures the active tab while the
// server HTML is parsed, so the pill is under it on the first
// paint instead of appearing only once React has hydrated.
renderBeforeHydration
className="absolute top-1/2 left-0 z-0 h-[calc(100%-6px)] w-(--active-tab-width) -translate-y-1/2 translate-x-(--active-tab-left) rounded-md bg-card shadow-(--shadow-sheet) transition-[translate,width,height] duration-(--duration-base) ease-(--ease-emphasized) group-data-vertical/tabs:top-0 group-data-vertical/tabs:h-(--active-tab-height) group-data-vertical/tabs:translate-y-(--active-tab-top)"
/>
) : null}
{children}
</TabsPrimitive.List>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
// The tab itself paints no background: the sliding pill behind it is
// what marks the selection, so it only owes the reader its ink.
"relative z-10 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-0.5 type-label whitespace-nowrap text-muted-foreground transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pe-1 has-data-[icon=inline-start]:ps-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-active:text-foreground",
// The line variant has no pill; it underlines in the accent, and the
// 2px rule lands on the row's own hairline. In a column the rail sits
// at the inline end, the side facing the panel, in either direction;
// the icon's tighter padding is on the inline axis for the same reason.
"after:absolute after:bg-brand after:opacity-0 after:transition-opacity after:duration-(--duration-base) after:ease-(--ease-standard) group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-end-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
// Base UI makes the open panel a tab stop, so it wears the kit's
// one focus treatment rather than no outline at all.
className={cn("flex-1 text-sm focus-ring", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }