v0.5

Tab Menu

Compound menu for switching between sections, with vertical or horizontal layouts and an animated active indicator.

Description

TabMenu is a compound component (TabMenu + TabMenuItem) that renders a <div role="group"> of real <button> items. The root tracks the active item and renders one of two animated indicators behind the scenes: a background pill that slides between items (the vertical-button and horizontal-button variants), or a line drawn on the side, top, or bottom of the active item (the vertical-line, horizontal-line, and vertical-button + left combinations).

Reach for it when you have a small set of mutually exclusive sections to switch between. Typical surfaces: settings sidebars, dashboard section nav, in-page anchor menus, and routed nav rendered as links via asChild. Each item supports a leading icon, a label, a trailing icon, and arbitrary extra children (e.g. a count badge or a status dot).

If each option swaps a content panel within the same surface, use Tabs. For 2 to 5 inline, mutually exclusive options without rich item content, use SegmentedControl. For hierarchical path navigation, use Breadcrumb. For persistent full-app navigation, use Sidebar. For paged content controls, use Pagination.

Installation

pnpm dlx @create-ui/cli add tab-menu

Anatomy

TabMenu provides a context that hands variant, size, and indicator down to its items, plus an optional value binding. Each TabMenuItem is a self-contained <button> (or any element via asChild).

<TabMenu value="..." onValueChange={...}>
  <TabMenuItem value="..." />
  <TabMenuItem value="..." />
  <TabMenuItem value="..." />
</TabMenu>

Usage

import { TabMenu, TabMenuItem } from "@/components/ui/tab-menu"
<TabMenu defaultValue="activity">
  <TabMenuItem value="overview" label="Overview" />
  <TabMenuItem value="activity" label="Activity" />
</TabMenu>

Examples

Variants

variant picks the layout and the indicator style. vertical-button and horizontal-button render a sliding background pill; vertical-line and horizontal-line draw a line on the active item's edge.

vertical-button

vertical-line · top indicator

horizontal-line · bottom indicator

horizontal-button

Sizes

Three sizes: sm, md, and lg (default). Padding, type, icon size, and item radius all scale together.

Indicators

indicator is the placement of the active-item line. The intentional pairings are vertical-button with left, vertical-line with top or bottom, and horizontal-line with top or bottom. horizontal-button does not use an indicator. Other combinations render without breaking, but the result is not designed for.

vertical-button · left

vertical-line · top

horizontal-line · bottom

With Icon

Pass leadingIcon and/or trailingIcon to any item. Icon sizing is driven by the active size, so you don't size the SVG yourself.

With Badge

Items accept arbitrary children alongside the label prop, which renders next to the label inside the item's content slot. Useful for unread counts, status dots, or "New" tags.

Disabled

Set disabled on an item to skip its click handler and apply the disabled visual treatment. The item also gets aria-disabled when rendered via asChild.

Controlled

Pass value and onValueChange to drive selection from your own state. Use uncontrolled defaultValue for simple in-page switchers; reach for controlled when other UI needs to react to the change (e.g. routing, persisting to URL).

Section: activity

Set asChild on each item and pass a next/link (or any anchor) as the child. The item forwards its slots and active state to the link, so the indicator animation and styling still work.

Accessibility

The root is a role="group". Each item is a real <button> (or the element passed via asChild) with aria-pressed reflecting the active state and aria-disabled when disabled. Every item is its own tab stop; there is no roving tabindex.

KeyDescription
TabMoves focus across items; each item is a tab stop.
Space EnterActivates the focused item.

ARIA notes:

  • The root sets role="group". Pair it with aria-label or aria-labelledby when the surrounding context doesn't already describe the menu.
  • Items set aria-pressed to true or false so screen readers announce the toggle state.
  • In asChild mode the underlying element (e.g. <a>) controls its own semantics; aria-pressed is omitted and aria-disabled is set when disabled is passed.
  • Disabled items skip click handlers and onValueChange.
  • If items only show an icon, add aria-label on each item so the button announces its purpose.

Styling

Tailwind override: pass className to merge Tailwind classes with the component's CVA classes (via cn()):

<TabMenu className="w-full">
  <TabMenuItem className="flex-1" value="overview" label="Overview" />
</TabMenu>

Data slots and attributes: the component sets these for CSS targeting.

  • data-slot="tab-menu" on the root element.
  • data-slot="tab-menu-active-bg" on the sliding background pill (rendered for vertical-button and horizontal-button variants).
  • data-slot="tab-menu-active-line" on the animated indicator line (rendered when indicator is set to left, top, or bottom).
  • data-slot="tab-menu-item" on each item.
  • data-slot="tab-menu-item-icon" with data-position="leading" | "trailing" on each icon wrapper.
  • data-slot="tab-menu-item-content" on the inner content wrapper.
  • data-slot="tab-menu-item-label" on the label wrapper.
  • data-variant="<variant>", data-size="<size>", data-indicator="<indicator>" on both root and item.
  • data-state="on" | "off" and data-value="<value>" on each item.

Target the active item in CSS:

[data-slot="tab-menu-item"][data-state="on"] {
  /* ... */
}
  • Tabs: use this when each option swaps a content panel within the same surface.
  • SegmentedControl: use this for 2 to 5 inline mutually exclusive options without rich item content.
  • Breadcrumb: use this for hierarchical path navigation.
  • Sidebar: use this for persistent full-app navigation.
  • Pagination: use this for paged content controls.

API Reference

TabMenu

Root that owns selection state and provides the variant context. Extends React.ComponentProps<"div">, so standard div attributes (id, aria-label, aria-labelledby, etc.) are accepted.

Props

PropTypeDefaultDescription
valuestring-Controlled selected value.
defaultValuestring-Uncontrolled initial value.
onValueChange(value: string) => void-Fires when an item with a value is activated.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-One or more TabMenuItem children.

Variants

VariantOptionsDefaultDescription
variant"vertical-button" "vertical-line" "horizontal-line" "horizontal-button""vertical-button"Layout and active-indicator style. Button variants render a sliding background pill; line variants draw an edge line on the active item.
size"sm" "md" "lg""lg"Size scale; controls padding, type, icon size, and item radius.
indicator"left" "top" "bottom"-Placement of the active-item line. Designed pairings: vertical-button + left, vertical-line + top | bottom, horizontal-line + top | bottom. horizontal-button uses no indicator.

TabMenuItem

One item in the menu. Extends Omit<React.ComponentProps<"button">, "value">. Renders a <button> by default, or any element via asChild (Radix Slot).

Props

PropTypeDefaultDescription
valuestring-Value committed to the parent's onValueChange when the item is activated.
selectedboolean-Forces the active state. Useful when you don't bind a value on the root.
disabledboolean-Disables the button; click handlers are skipped and aria-disabled is set.
leadingIconReact.ReactNode-Icon rendered before the label.
trailingIconReact.ReactNode-Icon rendered after the label.
labelReact.ReactNode-Item label. In asChild mode the child's text content is used as the label.
asChildbooleanfalseRender as the child element via Radix Slot; useful for links and custom triggers.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-Extra content rendered alongside the label inside the item (e.g. a badge or status).