Tabs
Organise content into switchable panels with animated indicators, four variants, five sizes, five intents, icon/badge/closable support, vertical orientation, and full RTL/keyboard navigation.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Tabs, TabsList, Tab, TabsPanel } from "@tessinaui/ui";<Tabs defaultValue="account">
<TabsList>
<Tab value="account">Account</Tab>
<Tab value="password">Password</Tab>
<Tab value="notifications">Notifications</Tab>
</TabsList>
<TabsPanel value="account">
Manage your account settings and preferences.
</TabsPanel>
<TabsPanel value="password">
Change your password and enable two-factor authentication.
</TabsPanel>
<TabsPanel value="notifications">
Configure which notifications you receive.
</TabsPanel>
</Tabs>Examples
Default
A basic set of tabs with the default line variant and switchable panels.
Variants
Four visual styles — line, enclosed, pill, and soft.
Sizes
Five sizes from xs to xl.
Intents
Colour the active indicator and text with none, error, warning, success, or info.
Icons & Badges
Tabs with leading icons, count and dot badges, plus a disabled tab.
Vertical
A vertical tab list with panels rendered alongside.
Rounded
Every painted corner — tab, badge, close affordance, menu popup — steps with the
rounded prop.
Icon above label
iconPosition="top" stacks the icon over the label for the taller, app-like
strip. The badge shares the label's line rather than taking a third row.
Icon only
isLabelHidden hides the label visually and keeps it as the accessible name.
Overflow menu
More tabs than fit: hand the rest to TabsList's menu slot.
Closable
Close by pointer on the ×, or with Delete / Backspace on the focused tab.
Link tabs
href renders a tab as an anchor — and switches the whole strip to the
navigation pattern, because that is what it has become. A tab controls a panel
in this view; a link changes the URL, and claiming the ARIA tabs pattern for one
tells a screen-reader user that something on this page is about to change when
it is not.
Tabs mode (no href) | Nav mode (href) | |
|---|---|---|
| Markup | role="tablist" / role="tab" | <nav> landmark, <ul> / <li> / <a> |
| Active marker | aria-selected | aria-current="page" |
| Keyboard | one tab stop, arrows move, Enter selects | every link is a tab stop, Enter follows |
| Panels | <TabsPanel> per tab | none — the route renders the content |
| Indicator | one element that travels between tabs | the active link paints its own |
aria-label on TabsList is required in nav mode: it names the landmark,
and two unnamed landmarks are indistinguishable in a screen reader's landmark
list. Development warns if it is missing, if a strip mixes href and plain
tabs, or if a <TabsPanel> is rendered beside a nav strip.
To keep the tabs pattern on links anyway, set role="tablist" on TabsList
explicitly — that is the documented opt-out and it restores the pre-0.3.0
behaviour.
On a coloured surface
tone="on-color" derives every ink from the surface's own, so one strip works
on a hero, a banner or a photo. Inside a <Surface> it is inherited — no prop
needed.
Loading
TabsSkeleton is the strip's own box: it mirrors variant, orientation,
fullWidth and iconPosition, so nothing shifts when content arrives.
API Reference
Tab values
A tab is identified by a TabsValue — string | number. Tabs.value,
Tabs.defaultValue, Tab.value, TabsPanel.value and every
TabsMenuOption.value all take one, and a root's value also accepts null
for "no tab selected".
This was any until 0.3.0, inherited from Base UI's own TabsTabValue. Object
values are no longer accepted — pass the object's own id instead:
// Before
<Tab value={section}>…</Tab>
<TabsPanel value={section}>…</TabsPanel>
// After
<Tab value={section.id}>…</Tab>
<TabsPanel value={section.id}>…</TabsPanel>Objects never worked properly anyway: TabsMenu matched options by reference
and keyed them by String(value), so every object collapsed to
"[object Object]" and three tabs shared one React key.
Tabs (Root) props
| Prop | Type | Default | Description |
|---|---|---|---|
value | TabsValue | null | — | Controlled active tab value (string | number; null selects nothing). Pass it for the whole life of the component — a root is controlled or uncontrolled, never both |
defaultValue | TabsValue | null | — | Initial tab for an uncontrolled root. Omit both this and value and Base UI selects the first tab |
onValueChange | (value: TabsValue | null, details?) => void | — | Fires once per selection, from a tab click or an overflow-menu pick |
variant | "line" | "enclosed" | "pill" | "soft" | "line" | Visual style of the tab bar |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size of all tabs |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Corner rounding for pill/soft/enclosed variants. No xl step — the shared vocabulary lists one, Tabs stops at lg |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Colour intent for active indicator/text |
fullWidth | boolean | false | Tabs stretch to fill container width |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction of tabs |
dir | "ltr" | "rtl" | — | Writing direction. Flips the layout and the arrow-key axis (the root wraps itself in a Base UI DirectionProvider) |
iconPosition | "start" | "top" | "start" | Where each tab's icon sits. "top" stacks it above the label for the taller, app-like strip |
motion | "draw" | "fade" | "none" | "draw" | How the active indicator moves. draw travels between tabs; fade jumps to the new tab and fades in, with no travel; none stops the indicator and the label tweens too. All three flatten to an instant change under prefers-reduced-motion: reduce, so none is not the OS-level accessibility answer — reach for it on a long strip, or to wire your own in-app "reduce motion" setting. No-op on variant="enclosed", which renders no indicator |
tone | "default" | "on-color" | inherited | "on-color" derives every ink from the surface's own, for a coloured, dark or media-backed plate. Unset, it follows the nearest <Surface> |
hasDivider | boolean | false | Bottom rail under the list for pill/soft (line/enclosed always carry one) |
TabsList props
| Prop | Type | Default | Description |
|---|---|---|---|
activateOnFocus | boolean | false | Activate a tab as soon as arrow keys focus it. Leave off when a panel is expensive to render — Carbon's rule: automatic for cheap panels, manual for slow ones |
loopFocus | boolean | true | Loop keyboard focus at list edges. Material warns that an endlessly looping strip can trap a screen-reader user moving linearly — consider false for long tab sets |
menu | ReactNode | — | Overflow menu rendered beside the tab list. Pass <TabsMenu /> here |
Tab props
| Prop | Type | Default | Description |
|---|---|---|---|
value | TabsValue | required | Unique tab identifier (string | number) |
disabled | boolean | false | Disable this tab |
icon | ReactNode | — | Leading icon element |
selectedIcon | ReactNode | — | Icon swapped in while selected (e.g. filled variant); falls back to icon |
badge | number | boolean | — | Count badge (number) or dot indicator (true) |
endContent | ReactNode | — | Free-form trailing slot — status dots, custom chips |
isLabelHidden | boolean | false | Icon-only tab: hides the label visually, keeps it as the accessible name |
href | string | — | Renders the tab as an anchor for URL-driven navigation |
closable | boolean | false | Show a close affordance and enable Delete/Backspace to close |
onClose | () => void | — | Called when the tab is closed, by pointer or by key |
closeLabel | string | "Press Delete to close" | Hint read after the tab's name. Translate it — it is UI copy |
TabsMenu props
Overflow "More" dropdown for tabs that don't fit inline. Pass it to TabsList's
menu slot — it renders as a sibling of the tab list, styled to match a tab:
<TabsList menu={<TabsMenu options={overflow} />}>It is deliberately not a child. role="tablist" may only own role="tab"
children, and this trigger is a menu button (aria-expanded, aria-haspopup),
so nesting it there is an aria-required-children violation and leaves it
invisible to the roving tabindex. A TabsMenu passed as a child still works —
it is hoisted out with a development warning.
Selecting an option activates the matching TabsPanel, so Tabs needs value
or defaultValue for the menu to have somewhere to write.
| Prop | Type | Default | Description |
|---|---|---|---|
options | TabsMenuOption[] | required | { value: TabsValue, label, disabled? } items shown in the dropdown |
label | ReactNode | "More" | Trigger label while no menu option is active |
disabled | boolean | false | Disable the trigger |
TabsMenu forwards a ref and any button attributes to its trigger.
TabsSkeleton props
Mirrors the real strip's box (CONVENTIONS §2b), so pass it whatever you passed
Tabs.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Matches the real tabs' size |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Matches the real tabs' rounding |
variant | "line" | "enclosed" | "pill" | "soft" | "line" | Paints the same track — a pill strip has a filled, padded, rounded one |
orientation | "horizontal" | "vertical" | "horizontal" | Matches the real strip's direction |
fullWidth | boolean | false | Slots stretch instead of hugging |
iconPosition | "start" | "top" | "start" | A stacked strip is a size step taller |
tabs | number | 3 | Number of placeholder tabs |
showIcons | boolean | false | Mirrors each tab's icon |
showBadges | boolean | false | Mirrors each tab's count badge |
chars | number | 8 | Label length, in characters |
labels | number[] | — | Per-tab label length, overriding chars |
badges | number[] | — | Which tab indices carry a badge, overriding showBadges |
TabsPanel props
| Prop | Type | Default | Description |
|---|---|---|---|
value | TabsValue | required | Must match a Tab value (string | number) |
keepMounted | boolean | false | Keep panel in DOM when hidden |
Variants
Line (default)
Bottom underline indicator that slides between tabs. The most common pattern.
Enclosed
Card-style tabs where the active tab connects visually to the content panel with matching borders.
Pill
Active tab highlighted with a rounded pill background. Indicator slides with animation.
Soft
Subtle rounded-rectangle background on the active tab.
Keyboard Navigation
| Key | Action |
|---|---|
Tab | Move focus to the tab list, then to the active panel |
ArrowLeft / ArrowRight | Move between tabs (horizontal) |
ArrowUp / ArrowDown | Move between tabs (vertical) |
Home / End | Jump to first/last tab |
Enter / Space | Activate the focused tab (the default; with activateOnFocus the arrow keys already did it) |
Delete / Backspace | Close the focused tab, when it is closable |
Accessibility
Implements the WAI-ARIA Tabs pattern on Base UI primitives. That specification is the test plan for this component.
Activation is manual by default — arrow keys move focus, Enter or
Space selects. Set activateOnFocus on TabsList for automatic
activation. The two are visually identical, so decide deliberately: automatic
feels direct when panels are cheap, manual avoids firing expensive renders as
someone arrows past.
role="tablist"/role="tab"/role="tabpanel", witharia-selectedon the active tab andaria-controls/aria-labelledbylinking each pair.- The strip is one tab stop (roving tabindex); Tab from it moves into the panel, which is focusable so it can be scrolled by keyboard.
- A disabled tab stays reachable and is marked
aria-disabledrather than removed from the order — a tab that silently vanishes is undiscoverable to a screen-reader user. It cannot become selected. - Icon-only tabs (
isLabelHidden) keep their label as the accessible name, rendered visually hidden. The tab is never nameless, whatever the child is. - Closable tabs: the × is presentational (
aria-hidden) and the keyboard path is Delete or Backspace, announced throughcloseLabel. ARIA treatsrole="tab"children as presentational, so a focusable control cannot legally live inside a tab. - The overflow menu trigger renders outside the tab list — a
role="tablist"may only own tabs, and the trigger is a menu button. - Link tabs are not tabs. A
<Tab href>puts the strip in nav mode: a named<nav>landmark,<ul>/<li>/<a>,aria-current="page", and no roving tabindex — every link is its own tab stop, and the arrow keys are left to the page. See Link tabs for the full comparison. - Touch targets meet 44x44 px on phones (WCAG 2.5.5) from the tab's own box.
- Every animation — the indicator, the press scale, the menu chevron and the
popup — is disabled under
prefers-reduced-motion.
Known limitation
The warning line indicator measures 2.17:1 against the page in light mode,
under the 3:1 non-text floor (1.4.11) — the amber token's ceiling, shared with
other warning affordances in the system. Selection is not carried by the bar
alone: the active label also switches colour, at 4.82:1 on the tint and 5.00:1
on the page. Every other intent's indicator clears 3:1.
RTL
Pass dir="rtl" to the root. It flips the layout — every rule is written in
logical properties — and the arrow-key axis, by wrapping the root in Base
UI's DirectionProvider. Setting the DOM attribute alone would flip the visuals
while leaving ArrowLeft moving the wrong way.