Skip to content
Monogem

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

A summary of this record — status, owner, and the three most recent changes.

Manual activation

Name, email, and password. Arrow keys move focus here; the panel only switches on `Enter` or `Space`.

Overflow scrolls horizontally

The tab list scrolls within itself instead of wrapping to a second row.

Code Example

tabs-showcase.tsx
"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

StateTrigger 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

TabsNavigation links
ChangesVisible content within the current viewThe page or route itself
URLUnchanged (unless an implementation deliberately syncs a query param)Changes
Browser back buttonNot expected to step through tab changesExpected to step through page history
Use forAlternate views of one subject, same data contextGenuinely 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.

TokenWhere used
--foregroundSelected and hovered trigger text
--muted-foregroundUnselected trigger text
--primarySelection underline
--borderTab list's baseline rule
--ringFocus-visible ring
--scale-0-5Underline thickness — Color's emphasis-border recipe
--scale-3Trigger padding-x
--scale-2Trigger padding-y
Label / button text recipeTrigger 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 (true on the active one, false on the rest) and aria-controls pointing at its panel's id; each panel sets aria-labelledby pointing back at its trigger's id.
  • Only the selected trigger is in the natural tab order (tabindex="0"); the rest are tabindex="-1" and reachable by arrow keys — a single roving tab stop, not one per trigger.
  • Hidden panels are removed from the accessibility tree (hidden attribute or equivalent), not just visually clipped.
  • Under manual activation, aria-selected changes 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.

KeyBehavior
TabMoves 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 / RightMoves focus to the previous/next trigger, wrapping at the ends
Home / EndJumps focus to the first/last trigger

Arrow keys always move focus between triggers; whether they also move selection depends on the activation mode:

ModeArrow keysConfirmUse when
Automatic (Version 1 preference)Move focus and selection immediatelyNot neededSwitching panels produces no noticeable latency — the lower-friction default for a same-page tab list
ManualMove focus only; selection stays putEnter / Space activates the focused triggerPanel 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 (hidden attribute 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 so Tab from 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 extra tabindex="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.