Tabs
Switch between sibling views inside the same page context. Radix Tabs primitive with TWO Yuno-styled variants sharing the same API: `underline` (the Dashboard default — 2px primary underline on the active tab, muted default text, accent-tinted hover pill) and `pill` (the shadcn segmented control — bg-muted track with a white active surface). Same Root / List / Trigger / Content compound for both; the visual variant is set once on TabsList and read via Context by each Trigger.
Anatomy
Four compound parts shared by both variants. Root manages state; List is the row of triggers; each Trigger is a tab button; Content is the panel that shows when its trigger is active.
Panel content lands here.
- 1Tabs (Root)
The controlled/uncontrolled state container. defaultValue = uncontrolled initial tab. value + onValueChange = controlled. orientation='vertical' is supported but rarely used in the Dashboard.
- 2TabsList
The row of triggers. Sets the visual variant via a variant prop (default 'underline'): 'underline' → inline-flex items-center gap-2 (no chrome); 'pill' → h-9 rounded-md bg-muted p-1 (segmented control). The variant flows to every Trigger inside via Context.
- 3TabsTrigger
One tab. In underline: outer py-1.5 with a border-b-2 transparent (turns primary when active) + inner span with px-2.5 py-2 rounded-md text-sm text-muted-foreground that picks up bg-accent + text-accent-foreground on hover. In pill: standard shadcn — px-3 py-1 rounded-sm with bg-background + shadow-xs on active.
- 4TabsContent
The panel. mt-4 in the kit (bumped from shadcn's mt-2 to give the primary underline breathing room). Focus-visible ring for keyboard users.
Variants
Two visual variants over the same Radix state. Pass variant on TabsList; every Trigger inside inherits it via Context — no prop drilling.
Panel content for Overview.
<Tabs defaultValue="overview">
<TabsList variant="underline">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rules">Rules</TabsTrigger>
<TabsTrigger value="lists">Lists</TabsTrigger>
</TabsList>
<TabsContent value="overview">…</TabsContent>
</Tabs>Rendered as a list.
<Tabs defaultValue="list">
<TabsList variant="pill">
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="grid">Grid</TabsTrigger>
</TabsList>
<TabsContent value="list">…</TabsContent>
</Tabs>States
Underline — default: text-muted-foreground, no chrome. Hover (inactive): bg-accent + text-accent-foreground on the inner text pill; the underline stays transparent. Active: text-foreground + 2px border-primary underline. Focus-visible: keyboard ring on the outer trigger. Disabled: opacity-50 + pointer-events-none. Pill — default: text-muted-foreground; active: bg-background + text-foreground + shadow-xs (white pill lifted over the muted track).
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
Common in Dashboard nav tabs: a tab label + a small BadgeNumber counter. The counter picks up bg-primary so it reads as a soft signal, not a warning. Wrap the count in <BadgeNumber> or a compact inline span.
<Tabs defaultValue="alerts">
<TabsList variant="underline">
<TabsTrigger value="alerts">
Alerts <BadgeNumber>8</BadgeNumber>
</TabsTrigger>
<TabsTrigger value="rules">
Rules <BadgeNumber>12</BadgeNumber>
</TabsTrigger>
<TabsTrigger value="lists">
Lists <BadgeNumber>3</BadgeNumber>
</TabsTrigger>
</TabsList>
</Tabs>For sharable pages (transactions, rules), persist the active tab in the URL via query string. Use value + onValueChange, sync with a router param. The kit example uses local state; wire it to your router in production.
Active tab (state): overview
Overview content.
const [tab, setTab] = React.useState<string>("overview");
// wire to router.push({ query: { tab } }) in prod
<Tabs value={tab} onValueChange={setTab}>
<TabsList variant="underline">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="overview">…</TabsContent>
<TabsContent value="details">…</TabsContent>
</Tabs>Use the pill variant for compact inline switches — a small view-mode toggle inside a Card header (list vs grid, chart period). It's tighter than the underline and lives comfortably in constrained surfaces.
Volume by day
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Volume by day</h3>
<Tabs defaultValue="7d">
<TabsList variant="pill">
<TabsTrigger value="7d">7d</TabsTrigger>
<TabsTrigger value="30d">30d</TabsTrigger>
<TabsTrigger value="90d">90d</TabsTrigger>
</TabsList>
</Tabs>
</div>The most common Dashboard shape: tabs above, a Card wrapping the panel content below. mt-4 default gap keeps the primary underline visible above the Card border.
<Tabs defaultValue="overview">
<TabsList variant="underline">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="rounded-lg border bg-card p-6">…</div>
</TabsContent>
</Tabs>Import
Single compound import from @/components/ui/tabs.
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";Props
Radix Tabs props on Root/List/Trigger/Content. The Yuno-specific prop is TabsList.variant:
| Prop | Type | Default | Description |
|---|---|---|---|
| Tabs.defaultValue | string | — | Uncontrolled initial active tab (matches a Trigger's value). Use for first render — Radix keeps state internally after that. |
| Tabs.value / onValueChange | string / (value: string) => void | — | Controlled pair. Reach for these when the tab state needs to live in the URL, in Zustand, or in the parent form state. |
| Tabs.orientation | "horizontal" | "vertical" | "horizontal" | Layout axis. Vertical is supported but rare in the Dashboard — the sidebar already covers that pattern. |
| TabsList.variant | "underline" | "pill" | "underline" | Yuno-specific. Sets the visual treatment for the whole compound via Context — every Trigger inside picks it up without prop drilling. 'underline' = page-level Yuno tabs (2px primary underline). 'pill' = compact segmented control (shadcn style). |
| TabsTrigger.value | string | — | Required. Matches Tabs.defaultValue / value and pairs the Trigger with its Content sibling. |
| TabsTrigger.disabled | boolean | false | Disables the tab (opacity-50, pointer-events-none). Radix skips it in keyboard nav. |
| TabsContent.value | string | — | Required. Matches the Trigger's value. Radix renders exactly one Content at a time. |
| TabsContent.forceMount | boolean | false | Radix escape hatch — force-render inactive content (useful for animation libraries that need the DOM present). Rarely needed in the Dashboard. |
When to use
- 2–5 sibling views under the same page context (Overview / Rules / Lists).
- Filter across categories inside a page.
- View-mode toggles inside a Card (pill variant only).
When not to use
- Navigating to a different page or scope — use Sidebar navigation.
- More than ~7 tabs — the row starts wrapping or scrolling and IA gets muddy.
- Read-only categorization — Tabs imply the user can switch and see distinct content.
Usage
- Keep tab labels short (1–2 words).
- Default to the most-used tab so first render lands on the common case.
- Persist the active tab in the URL when it's a shareable state.
- Use the underline variant for page-level tabs; the pill variant only for inline switches inside a component.
- Pair labels with a BadgeNumber counter when the count is a first-class signal (Insights, Alerts).
- Don't hide critical primary actions behind a non-default tab.
- Don't mix Tabs with a Sidebar for the same navigation level.
- Don't use the pill variant for page-level tabs — the muted track was designed for compact segmented switches, not for the top of a page.
- Don't override the underline color — bg-primary is the Yuno tab-active signal.
- Don't stack two Tabs rows in the same view.
Related
Cross-links to atoms and patterns you may reach for next.
- Sidebar (organism)For navigating between routes / scopes — Tabs are strictly for sibling views within one route.
- Toggle groupSimilar visual for exclusive/multi-select option groups (not view switching).
- Radio groupFor single-select choices inside forms — not for switching content areas.
- BadgeBadgeNumber composes cleanly next to the tab label for counters.