DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
BreadcrumbTabsPaginationStepperMenu ItemToolbarMenubarNavigation MenuAccordionCollapsibleCommandBottom NavTop Header MobileTop Header DesktopSidebar
AI agentsContributing
  1. Components
  2. Tabs

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/ui

When 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Tabs, TabsList, Tab, TabsPanel } from "@tessinaui/ui";
example.tsx
<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)
Markuprole="tablist" / role="tab"<nav> landmark, <ul> / <li> / <a>
Active markeraria-selectedaria-current="page"
Keyboardone tab stop, arrows move, Enter selectsevery link is a tab stop, Enter follows
Panels<TabsPanel> per tabnone — the route renders the content
Indicatorone element that travels between tabsthe 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:

example.tsx
// 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

PropTypeDefaultDescription
valueTabsValue | 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
defaultValueTabsValue | 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
fullWidthbooleanfalseTabs stretch to fill container width
orientation"horizontal" | "vertical""horizontal"Layout direction of tabs
dir"ltr" | "rtl"inheritedWriting 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>
hasDividerbooleanfalseBottom rail under the list for pill/soft (line/enclosed always carry one)

TabsList props

PropTypeDefaultDescription
activateOnFocusbooleanfalseActivate 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
loopFocusbooleantrueLoop keyboard focus at list edges. An endlessly looping strip can trap a screen-reader user moving linearly — consider false for long tab sets
menuReactNode—Overflow menu rendered beside the tab list. Pass <TabsMenu /> here

Tab props

PropTypeDefaultDescription
valueTabsValuerequiredUnique tab identifier (string | number)
disabledbooleanfalseDisable this tab
iconReactNode—Leading icon element
selectedIconReactNode—Icon swapped in while selected (e.g. filled variant); falls back to icon
badgenumber | boolean—Count badge (number) or dot indicator (true). 0 renders nothing
badgeLabelstring—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
endContentReactNode—Free-form trailing slot — status dots, custom chips
isLabelHiddenbooleanfalseIcon-only tab: hides the label visually, keeps it as the accessible name
hrefstring—Renders the tab as an anchor for URL-driven navigation
closablebooleanfalseShow a close affordance and enable Delete/Backspace to close
onClose() => void—Called when the tab is closed, by pointer or by key
closeLabelstring"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:

example.tsx
<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.

PropTypeDefaultDescription
optionsTabsMenuOption[]required{ value: TabsValue, label, disabled? } items shown in the dropdown
labelReactNode"More"Trigger label while no menu option is active
disabledbooleanfalseDisable 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.

PropTypeDefaultDescription
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
fullWidthbooleanfalseSlots stretch instead of hugging
iconPosition"start" | "top""start"A stacked strip is a size step taller
tabsnumber3Number of placeholder tabs
showIconsbooleanfalseMirrors each tab's icon
showBadgesbooleanfalseMirrors each tab's count badge
charsnumber8Label length, in characters
labelsnumber[]—Per-tab label length, overriding chars
badgesnumber[]—Which tab indices carry a badge, overriding showBadges

TabsPanel props

PropTypeDefaultDescription
valueTabsValuerequiredMust match a Tab value (string | number)
keepMountedbooleanfalseKeep 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

KeyAction
TabMove focus to the tab list, then to the active panel
ArrowLeft / ArrowRightMove between tabs (horizontal)
ArrowUp / ArrowDownMove between tabs (vertical)
Home / EndJump to first/last tab
Enter / SpaceActivate the focused tab (the default; with activateOnFocus the arrow keys already did it)
Delete / BackspaceClose 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", with aria-selected on the active tab and aria-controls / aria-labelledby linking 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-disabled rather 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 through closeLabel as the tab's description (never part of its name). ARIA treats role="tab" children as presentational, so a focusable control cannot legally live inside a tab. Once your onClose removes 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. badgeLabel is 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.

BreadcrumbPagination

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesIntentsIcons & BadgesVerticalRoundedIcon above labelIcon onlyOverflow menuClosableLink tabsOn a coloured surfaceLoadingAPI ReferenceTab valuesTabs (Root) propsTabsList propsTab propsTabsMenu propsTabsSkeleton propsTabsPanel propsVariantsLine (default)EnclosedPillSoftKeyboard NavigationAccessibilityKnown limitationRTL