Skip to main content

Tabs

Tabs switch between panels of content. The root is Headless UI's TabGroup (roving focus, arrow keys, role="tablist" / tab / tabpanel wiring); the compound parts read variant, size, orientation and fullWidth from the root so you set them once.

On phones the list scrolls horizontally with a hidden scrollbar instead of wrapping, and on a coarse pointer each tab is at least 44px tall.

import Tabs from '@zuilib/primitives/tabs'

Variants

underline draws a --primary bar under the selected tab. pills is a segmented control on a --muted track. enclosed frames the selected tab like a folder tab joined to the panel edge.

Loading example

Sizes and icons

sm, md and lg read --control-height-*, --control-padding-x-* and the matching --text-* step. icon renders before the label, sized to the tab; the slot is aria-hidden, so an icon-only tab needs an aria-label.

Loading example

Vertical and full width

orientation="vertical" stacks the list beside the panels and makes the arrow keys move up and down. fullWidth stretches the tabs to share the list (flex-1).

Loading example

Controlled and manual

selectedIndex + onSelectedIndexChange controls the selection; defaultSelectedIndex is the uncontrolled start. activationMode="manual" makes the arrow keys only move focus, so a tab is selected with Enter or Space (the automatic default selects on focus).

Loading example

Props

TabsProps extends the <div> attributes with:

PropTypeDefaultDescription
selectedIndexnumberControlled selected tab
defaultSelectedIndexnumber0Uncontrolled initial tab
onSelectedIndexChange(index: number) => void
variant'underline' | 'pills' | 'enclosed''underline'
size'sm' | 'md' | 'lg''md'--control-height-* / --control-padding-x-*
orientation'horizontal' | 'vertical''horizontal'vertical puts the list beside the panels; arrows move up / down
fullWidthbooleanfalseTabs share the list (flex-1)
activationMode'automatic' | 'manual''automatic'manual: arrows only move focus; Enter / Space selects
trackstringNames the change telemetry event (value is the new index)
classNamestringMerged last onto the root

Tabs.Tab

TabsTabProps extends the <button> attributes minus role, tabIndex, aria-selected and aria-controls (owned by Headless UI) with:

PropTypeDefaultDescription
iconReactNodeBefore the children, sized to size; aria-hidden
disabledbooleanfalse
autoFocusbooleanfalse
childrenReactNode | (state) => ReactNodeRender prop receives {selected, hover, focus, active, disabled}
classNamestringMerged last onto the tab

Tabs.Panel

TabsPanelProps extends the <div> attributes minus role and aria-labelledby with:

PropTypeDefaultDescription
keepMountedbooleanfalseKeeps unselected panels mounted but hidden
tabIndexnumber0
classNamestringMerged last onto the panel

Tabs.List (minus role / aria-orientation) and Tabs.Panels take the plain <div> attributes plus className. All parts are also named exports (TabsList, TabsTab, TabsPanels, TabsPanel).

Slots

SlotElementNotes
[data-slot="tabs"]divRoot; data-variant, data-size, data-orientation
[data-slot="tabs-list"]divrole="tablist"; scrolls along its axis when the tabs outgrow it
[data-slot="tabs-tab"]buttonrole="tab"; Headless UI data-selected / data-hover / data-focus / data-disabled
[data-slot="tabs-tab-icon"]spanWraps icon; aria-hidden
[data-slot="tabs-panels"]div
[data-slot="tabs-panel"]divrole="tabpanel"; data-selected / data-focus
/* Consumer CSS: thicker underline */
[data-slot="tabs"][data-variant="underline"] [data-slot="tabs-tab"] {
border-bottom-width: 3px;
}

Accessibility

  • Headless UI sets role="tablist" with aria-orientation, role="tab" with aria-selected / aria-controls, and role="tabpanel" with aria-labelledby. The aria-controls / aria-labelledby ids resolve from refs, so they appear after hydration rather than in server markup.
  • Roving tabindex: only the selected tab is in the tab order. Left / Right (Up / Down when vertical) move between tabs and wrap; Home / End jump to the first / last. Focus selects unless activationMode="manual", where Enter / Space select.
  • The keyboard focus ring is an inset --ring ring on data-[focus], on tabs and on the focused panel.
  • Disabled tabs are skipped by the arrow keys and get data-disabled (50% opacity, no pointer events).
  • The icon slot is aria-hidden; an icon-only tab must carry aria-label.