Skip to main content
Tabs organize related content into selectable panels. Each tab trigger activates a corresponding content panel, keeping the rest hidden. Features
  • Mouse, touch, and keyboard interaction
  • LTR and RTL keyboard navigation
  • Follows the Tabs ARIA authoring pattern — triggers and panels are semantically linked
  • Focus management for tab panels without focusable children
  • Optional animated indicator
  • Automatic and manual activation modes

Installation

npm install @zag-js/tabs @zag-js/react

Usage

import { useMachine, normalizeProps } from "@zag-js/react"
import * as tabs from "@zag-js/tabs"
import { useId } from "react"

const items = [
  { value: "react", label: "React", content: "React content" },
  { value: "vue", label: "Vue", content: "Vue content" },
  { value: "solid", label: "Solid", content: "Solid content" },
]

export function Tabs() {
  const service = useMachine(tabs.machine, {
    id: useId(),
    defaultValue: "react",
  })
  const api = tabs.connect(service, normalizeProps)

  return (
    <div {...api.getRootProps()}>
      <div {...api.getListProps()}>
        {items.map((item) => (
          <button key={item.value} {...api.getTriggerProps({ value: item.value })}>
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item) => (
        <div key={item.value} {...api.getContentProps({ value: item.value })}>
          {item.content}
        </div>
      ))}
    </div>
  )
}

Controlled tabs

Use value and onValueChange to control the selected tab externally.
const [value, setValue] = useState("react")

const service = useMachine(tabs.machine, {
  id: useId(),
  value,
  onValueChange(details) {
    // details => { value: string }
    setValue(details.value)
  },
})

Disabling a tab

Pass disabled: true to getTriggerProps to disable an individual tab.
<button {...api.getTriggerProps({ value: "vue", disabled: true })}>Vue</button>

Vertical orientation

const service = useMachine(tabs.machine, {
  id: useId(),
  orientation: "vertical",
})

Manual activation mode

By default, a tab is selected as soon as it receives focus. Switch to manual mode so tabs only activate with Enter or a click.
const service = useMachine(tabs.machine, {
  id: useId(),
  activationMode: "manual",
})

Animated indicator

Spread api.getIndicatorProps() on an indicator element to animate it between the active tab.
<div {...api.getListProps()}>
  {items.map((item) => (
    <button key={item.value} {...api.getTriggerProps({ value: item.value })}>
      {item.label}
    </button>
  ))}
  <div {...api.getIndicatorProps()} />
</div>
[data-part="indicator"] {
  --transition-duration: 0.2s;
  --transition-timing-function: ease-in-out;
}

Deselectable tabs

Allow clicking the active tab to clear the current selection.
const service = useMachine(tabs.machine, {
  id: useId(),
  deselectable: true,
})
When tab triggers are rendered as links, provide a navigate function to handle routing.
const service = useMachine(tabs.machine, {
  id: useId(),
  navigate(details) {
    // details => { value: string, node: HTMLAnchorElement, href: string }
    router.push(details.href)
  },
})

RTL support

const service = useMachine(tabs.machine, {
  id: useId(),
  dir: "rtl",
})

API reference

defaultValue
string
The initially selected tab value (uncontrolled).
value
string | null
The controlled selected tab value. Use with onValueChange.
orientation
"horizontal" | "vertical"
The orientation of the tab list. Defaults to "horizontal".
activationMode
"automatic" | "manual"
Whether tabs activate on focus or require an explicit selection. Defaults to "automatic".
loopFocus
boolean
Whether arrow key navigation wraps from the last to first tab. Defaults to true.
deselectable
boolean
Whether clicking the active tab clears the selection.
dir
"ltr" | "rtl"
The text direction. Defaults to "ltr".
onValueChange
(details: { value: string }) => void
Callback fired when the selected tab changes.
onFocusChange
(details: { focusedValue: string }) => void
Callback fired when the focused tab changes.
navigate
(details: { value: string, node: HTMLElement, href: string }) => void
Navigation handler for link-based tab triggers.

Styling

Selected state

[data-part="trigger"][data-state="active"] { /* selected tab trigger */ }
[data-part="content"][data-state="active"] { /* visible tab panel */ }

Disabled state

[data-part="trigger"][data-disabled] { /* disabled tab trigger */ }

Focused state

[data-part="trigger"]:focus-visible { /* individual focused trigger */ }
[data-part="list"][data-focus] { /* list when any trigger is focused */ }

Orientation

[data-part="root"][data-orientation="horizontal"] { /* horizontal root */ }
[data-part="root"][data-orientation="vertical"] { /* vertical root */ }
[data-part="list"][data-orientation="horizontal"] { /* horizontal list */ }
[data-part="list"][data-orientation="vertical"] { /* vertical list */ }
[data-part="trigger"][data-orientation="horizontal"] { /* horizontal trigger */ }
[data-part="trigger"][data-orientation="vertical"] { /* vertical trigger */ }
[data-part="indicator"][data-orientation="horizontal"] { /* horizontal indicator */ }
[data-part="indicator"][data-orientation="vertical"] { /* vertical indicator */ }

Keyboard interactions

KeyDescription
ArrowRight / ArrowDownMoves focus to the next tab trigger
ArrowLeft / ArrowUpMoves focus to the previous tab trigger
HomeMoves focus to the first tab trigger
EndMoves focus to the last tab trigger
Enter / SpaceSelects the focused tab (in manual activation mode)
TabMoves focus into the active tab panel

Build docs developers (and LLMs) love