v1.0

Tab Menu

Compound menu for switching between sections, with an animated active indicator.

Pro

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 slides an animated line beneath it as the selection changes.

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

The free build ships the horizontal-line layout with a sliding bottom indicator. Pro unlocks the full set of layout variants (vertical-button, vertical-line, horizontal-line, horizontal-button) and indicator positions (left, top, bottom).

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

Richer previews (the full set of layout variants and indicator positions) are marked with a Pro badge. The live demo still runs for everyone; the source needs a Pro seat.

Variants

The free build ships the horizontal-line layout. Pro unlocks vertical-button (a sliding pill), vertical-line (icon over label), and horizontal-button, so the menu can match wherever it sits.

Pro
Pro
Pro

Indicators

The active item is marked by a sliding line. The free build draws it on the bottom (shown above). Pro adds a top line and a vertical left rail for the vertical layouts.

Pro
Pro

Sizes

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

Pro

With Icon

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

Pro

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.

Pro

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.

Pro

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-line" on the animated indicator line beneath the active item.
  • 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-size="<size>" on both root and item.
  • data-active="true" | "false" on each item, the active-state styling hook (it drives the active colors and the sliding indicator). Prefer this for styling: it survives even when the item doubles as a Radix overlay (Tooltip, Popover, …) trigger. data-state="on" | "off" mirrors the same state, but a Radix overlay overwrites data-state on its own trigger, so it is unreliable in that case. data-value="<value>" on each item.

Target the active item in CSS:

[data-slot="tab-menu-item"][data-active="true"] {
  /* ... */
}
  • 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 the size 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.
size"sm" | "md" | "lg""md"Size scale; controls padding, type, icon size, and item radius.
variant"vertical-button" | "vertical-line" | "horizontal-line" | "horizontal-button""vertical-button"Pro: layout of the menu. The free build only ships horizontal-line.
indicator"left" | "top" | "bottom"-Pro: where the sliding active line sits (left is for the vertical layouts). The free build always draws it on the bottom.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-One or more TabMenuItem children.

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.
leadingReact.ReactNode-Icon rendered before the label.
trailingReact.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).