DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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)
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.

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

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"—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>
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 — Carbon's rule: automatic for cheap panels, manual for slow ones
loopFocusbooleantrueLoop 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
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)
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:

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

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

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. ARIA treats role="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.

TableTextarea

On this page

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