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.
pnpm add @tessinaui/uiWhen to use
- Peer views of one thing. Overview, Activity, Settings: panels of related content in the same view, one at a time, which people move between freely and in any order.
- Content that does not need to be seen side by side. Each panel stands on its own; nobody has to compare one tab against another.
- Tabs that are their own URLs. Use link tabs (
<Tab href>) so each tab can be bookmarked and shared. A tab controls a panel on this page; a link changes the page. - Short labels. One or two words that name what the panel holds. A strip
that does not fit scrolls inside itself; move rarely needed tabs into a
TabsMenu. - A strip that sits directly above the panel it controls, with one tab selected from the first render.
When not to use
- To pick a value or a view setting. There is no panel to reveal. Use a Segmented Control.
- For steps that must happen in order. Use Continue and Back buttons, with a segmented Progress to show position.
- For an app's top-level destinations on a phone. Use a Bottom Nav.
- For sections people read top to bottom. Use an Accordion, or put them on one page under headings, so nothing is hidden.
- To compare content across panels. Tabs hide all but one panel; show the content together instead.
- For long tab sets or nested strips. More than a handful of tabs, or a strip under a strip, hides structure; split the page or use a menu.
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. Give every
badge a badgeLabel: a dot has no text, so without one the tab's "new" state
is visible and silent.
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.
On a plate the pill and soft indicators are the inverse chip — the plate's
own ink, labelled in the colour that contrasts it, whatever the intent — and
the pill track and an active enclosed tab are the field's 8% well, so every
label clears 4.5:1 on the error, info and success plates.
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" | inherited | Writing direction. Flips the layout and the arrow-key axis. Unset, the tab set follows the page |
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 — automatic for cheap panels, manual for slow ones |
loopFocus | boolean | true | Loop keyboard focus at list edges. 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). 0 renders nothing |
badgeLabel | string | — | Screen-reader text for the badge, folded into the tab's name ("12 unread", "New"). The visible badge is then hidden from assistive tech. Without it a dot is not announced at all |
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: the active tab is an outlined box on the strip's rail. In a vertical strip it opens into the panel beside it; in a horizontal strip the rail runs underneath it.
Pill
The active tab is the solid inverse fill in a rounded pill, with its label in the fill's ink. The indicator slides between tabs.
Soft
The same solid inverse fill on the active tab, in a rounded rectangle.
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
VoiceOver + Safari: passed 2026-09-14. The list reads "Home and 2 more items, tab group", then "Home, selected, tab, 1 of 3"; Right arrow reads "Notifications 5, tab, 2 of 3" and "Settings, tab, 3 of 3". The demo's badge has since gained a badgeLabel, so that tab is now named "Notifications 5 unread" (accessibility tree, 2026-09-30); VoiceOver has not re-read it yet. NVDA and JAWS are untested.
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 throughcloseLabelas the tab's description (never part of its name). ARIA treatsrole="tab"children as presentational, so a focusable control cannot legally live inside a tab. Once youronCloseremoves the tab, focus moves to the tab after it, or the one before when it was the last — unless you kept the tab or moved focus yourself. - 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. - Badges are part of the tab's name.
badgeLabelis read after the label ("Inbox 12 unread") and the visible badge is hidden from assistive tech, so a count is not read twice. A dot badge has no text of its own and needs one. - Touch targets meet 44x44 px on touch screens (WCAG 2.5.5) — a coarse pointer at any width, tablets included — 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
On an RTL page the tab set mirrors on its own: the layout — every rule is
written in logical properties — and the arrow-key axis, so ArrowLeft
moves to the next tab. Pass dir="rtl" only to set a direction the page around
the tabs does not have.