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.

import Tabs from '@zuilib/components/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

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 + onChange controls the selection; defaultIndex is the uncontrolled start. manual makes the arrow keys only move focus, so a tab is selected with Enter or Space (by default focusing a tab selects it).

Loading example

Props

TabsProps extends the <div> attributes (minus onChange) with:

PropTypeDefaultDescription
selectedIndexnumberControlled selected tab
defaultIndexnumber0Uncontrolled initial tab
onChange(index: number) => void
variant'underline' | 'pills' | 'enclosed''underline'
size'sm' | 'md' | 'lg''md'--control-height-* / --control-padding-x-*
verticalbooleanfalseList beside the panels; arrows move up / down
fullWidthbooleanfalseTabs share the list (flex-1)
manualbooleanfalseArrows only move focus; Enter / Space selects
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
staticbooleanfalseAlways render the panel, ignoring selection
unmountbooleantruefalse keeps 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 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.