Component · Containers & Overlays
Tabs
Overview
Switches between several views of equal standing within the same page context — one panel visible at a time, the others fully hidden, no navigation or URL change involved. Use Tabs when the content underneath is a set of alternative views of the same subject ("Overview / Activity / Settings" on one record); use ordinary navigation links when the destinations are genuinely different pages or routes. See Tabs vs. navigation links.
Anatomy
Tab List → Tab Trigger(s) → Tab Panel(s).
- Tab List — the row of triggers,
role="tablist". - Tab Trigger — one clickable label per view,
role="tab", text-only baseline (no leading icon required, though one may be added following Icons' "default UI" size). - Tab Panel — the content region for the selected trigger,
role="tabpanel". Only the selected panel renders visible content; others are hidden, not just visually collapsed.
Live Example
Automatic activation
Manual activation
Overflow scrolls horizontally
Code Example
"use client";
import { Tabs, TabsList, TabsPanel, TabsTrigger } from "@/components/ui/tabs";
/**
* Live Tabs example — a horizontal `role="tablist"` with roving tabindex: `Tab`
* lands on the selected trigger, arrow keys move between triggers (wrapping),
* `Home` / `End` jump to the ends. The first group uses automatic activation
* (arrows move selection immediately — the Version 1 preference); the second
* uses manual activation (arrows move focus only, `Enter` / `Space` confirms).
*
* Each panel opens on plain text with nothing focusable before it, so it keeps
* the default `tabindex="0"` — a second `Tab` from the trigger lands inside the
* panel. A panel that began with a link or button would pass `focusable={false}`.
*/
export function TabsShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Automatic activation
</h3>
<Tabs defaultValue="overview">
<TabsList aria-label="Record sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="archived" disabled>
Archived
</TabsTrigger>
</TabsList>
<TabsPanel value="overview" className="pt-2 text-sm leading-normal">
A summary of this record — status, owner, and the three most recent
changes.
</TabsPanel>
<TabsPanel value="activity" className="pt-2 text-sm leading-normal">
Every event on this record in reverse chronological order.
</TabsPanel>
<TabsPanel value="settings" className="pt-2 text-sm leading-normal">
Visibility, notifications, and who can edit.
</TabsPanel>
<TabsPanel value="archived" className="pt-2 text-sm leading-normal">
Archived items (this trigger is disabled in the example).
</TabsPanel>
</Tabs>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Manual activation
</h3>
<Tabs defaultValue="account" activationMode="manual">
<TabsList aria-label="Preferences">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
<TabsTrigger value="team">Team</TabsTrigger>
</TabsList>
<TabsPanel value="account" className="pt-2 text-sm leading-normal">
Name, email, and password. Arrow keys move focus here; the panel
only switches on `Enter` or `Space`.
</TabsPanel>
<TabsPanel value="billing" className="pt-2 text-sm leading-normal">
Plan, payment method, and invoice history.
</TabsPanel>
<TabsPanel value="team" className="pt-2 text-sm leading-normal">
Members, roles, and pending invitations.
</TabsPanel>
</Tabs>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Overflow scrolls horizontally
</h3>
<div className="max-w-xs">
<Tabs defaultValue="mon">
<TabsList aria-label="Day">
{[
["mon", "Monday"],
["tue", "Tuesday"],
["wed", "Wednesday"],
["thu", "Thursday"],
["fri", "Friday"],
].map(([value, label]) => (
<TabsTrigger key={value} value={value}>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsPanel value="mon" className="pt-2 text-sm leading-normal">
The tab list scrolls within itself instead of wrapping to a second
row.
</TabsPanel>
{["tue", "wed", "thu", "fri"].map((value) => (
<TabsPanel
key={value}
value={value}
className="pt-2 text-sm leading-normal"
>
{value} panel.
</TabsPanel>
))}
</Tabs>
</div>
</section>
</div>
);
}Variants
Orientation
Horizontal only for Version 1. A horizontal tab list matches every current use case; vertical tabs (a sidebar-style list controlling a content pane) are a distinct enough layout to earn their own documented pattern later, not a same-page variant bolted onto this one.
Selection indicator
The selected trigger's text is --foreground with a --scale-0-5 (2px) underline in --primary,
flush against the tab list's own bottom --border. Unselected triggers are --muted-foreground
text, no underline. The underline is the same "emphasis border" recipe
Color already names for a thick focus outline — reused
here for a selection indicator instead.
States
| State | Trigger look |
|---|---|
| Selected | --foreground text, --primary underline, tabindex="0" |
| Unselected | --muted-foreground text, no underline, tabindex="-1" |
| Hover (unselected) | Text shifts to --foreground, underline stays absent — a preview of selection, not a partial one |
| Focus-visible | --ring on :focus-visible (keyboard focus only) — same --ring token and :focus-visible trigger as Button, drawn flush against the trigger (the offset geometry Button ships is not reused here) — see Color — Border width |
| Disabled (per-trigger) | Reduced opacity (opacity-50; Color — Disabled dimming), --muted-foreground text, removed from keyboard navigation and tabindex |
Optional pointer-down feedback: a brief one-step-darker text/underline shift while a trigger is being pressed, mirroring Button's Active step. This is a presentation detail, not a named state — Tabs' states are Selected, Unselected, Hover, Focus, and Disabled.
Usage Guidance
Tabs vs. navigation links
| Tabs | Navigation links | |
|---|---|---|
| Changes | Visible content within the current view | The page or route itself |
| URL | Unchanged (unless an implementation deliberately syncs a query param) | Changes |
| Browser back button | Not expected to step through tab changes | Expected to step through page history |
| Use for | Alternate views of one subject, same data context | Genuinely different destinations |
Reaching for Tabs to build a top-level site navigation bar is a misuse — that's a nav list of
links, not a tablist.
Tokens
Tabs uses existing Monogem semantic tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--foreground | Selected and hovered trigger text |
--muted-foreground | Unselected trigger text |
--primary | Selection underline |
--border | Tab list's baseline rule |
--ring | Focus-visible ring |
--scale-0-5 | Underline thickness — Color's emphasis-border recipe |
--scale-3 | Trigger padding-x |
--scale-2 | Trigger padding-y |
| Label / button text recipe | Trigger text (text-sm font-medium leading-snug), same as Button |
Do / Don’t
Do
- Use Tabs only for alternate views of the same subject, in the same page context.
- Keep trigger labels short, one to two words — they're labels, not sentences.
- Give every trigger a visible label; icon-only triggers still need
aria-label.
Don’t
- Use Tabs to build primary site navigation — that's a nav list of links.
- Wrap the tab list to a second row on overflow — scroll it instead.
- Disable the only remaining enabled trigger, or ship a tab list with a single trigger — if there's only one view, there's no need for Tabs.
Accessibility
role="tablist"on the container,role="tab"on each trigger,role="tabpanel"on each panel.- Each trigger sets
aria-selected(trueon the active one,falseon the rest) andaria-controlspointing at its panel'sid; each panel setsaria-labelledbypointing back at its trigger'sid. - Only the selected trigger is in the natural tab order (
tabindex="0"); the rest aretabindex="-1"and reachable by arrow keys — a single roving tab stop, not one per trigger. - Hidden panels are removed from the accessibility tree (
hiddenattribute or equivalent), not just visually clipped. - Under manual activation,
aria-selectedchanges only when a trigger is actually activated (Enter/Space), not merely focused — focus and selection are allowed to diverge until then.
Keyboard navigation
Tab triggers use roving tabindex: the selected trigger has tabindex="0" — the tab list's one
stop in the page's tab order — and every unselected trigger has tabindex="-1", reachable only by
arrow key, not by Tab.
| Key | Behavior |
|---|---|
Tab | Moves focus into the tab list (lands on the selected trigger) and, on a second press, out to the selected panel — the tab list is one stop in the page's tab order, not one stop per trigger |
Left / Right | Moves focus to the previous/next trigger, wrapping at the ends |
Home / End | Jumps focus to the first/last trigger |
Arrow keys always move focus between triggers; whether they also move selection depends on the activation mode:
| Mode | Arrow keys | Confirm | Use when |
|---|---|---|---|
| Automatic (Version 1 preference) | Move focus and selection immediately | Not needed | Switching panels produces no noticeable latency — the lower-friction default for a same-page tab list |
| Manual | Move focus only; selection stays put | Enter / Space activates the focused trigger | Panel loading or rendering causes a noticeable delay — confirms the switch is intentional before paying that cost |
Pick one mode per Tabs instance and keep it consistent — don't mix automatic and manual activation within the same tab list.
Panel display and focus
- The selected panel is displayed and associated with its trigger via
aria-labelledby(see Accessibility). - Unselected panels are hidden from interaction and the accessibility tree (
hiddenattribute or equivalent) — not just visually clipped. - A panel receives
tabindex="0"only when its first meaningful content isn't already keyboard focusable — e.g. a panel that opens directly on plain text, with no focusable element before it, needs the tabindex soTabfrom the trigger lands somewhere inside the panel. A panel that begins with an appropriately focusable element (a link, a button, a field) does not get an extratabindex="0"— that would create a redundant tab stop.
Responsive Behavior
When triggers exceed the available width, the tab list scrolls horizontally within itself; it never wraps to a second row (wrapping breaks the single-row list metaphor and misaligns with the panel below it). An implementation may add scroll-affordance cues (edge fade, arrow buttons) — those are presentation details on top of this contract, not a new anatomy part.
Related Components / Patterns
- Accordion — expandable sections rather than peer views.
- Navigation — how Tabs coexists with the other navigation primitives.