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
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>
)
}
<script setup>
import { useMachine, normalizeProps } from "@zag-js/vue"
import * as tabs from "@zag-js/tabs"
import { useId, computed } from "vue"
const items = [
{ value: "react", label: "React", content: "React content" },
{ value: "vue", label: "Vue", content: "Vue content" },
]
const service = useMachine(tabs.machine, {
id: useId(),
defaultValue: "react",
})
const api = computed(() => tabs.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<div v-bind="api.getListProps()">
<button
v-for="item in items"
:key="item.value"
v-bind="api.getTriggerProps({ value: item.value })"
>
{{ item.label }}
</button>
</div>
<div
v-for="item in items"
:key="item.value"
v-bind="api.getContentProps({ value: item.value })"
>
{{ item.content }}
</div>
</div>
</template>
import { useMachine, normalizeProps } from "@zag-js/solid"
import * as tabs from "@zag-js/tabs"
import { createMemo, createUniqueId, For } from "solid-js"
const items = [
{ value: "react", label: "React", content: "React content" },
{ value: "vue", label: "Vue", content: "Vue content" },
]
export function Tabs() {
const service = useMachine(tabs.machine, {
id: createUniqueId(),
defaultValue: "react",
})
const api = createMemo(() => tabs.connect(service, normalizeProps))
return (
<div {...api().getRootProps()}>
<div {...api().getListProps()}>
<For each={items}>
{(item) => (
<button {...api().getTriggerProps({ value: item.value })}>
{item.label}
</button>
)}
</For>
</div>
<For each={items}>
{(item) => (
<div {...api().getContentProps({ value: item.value })}>
{item.content}
</div>
)}
</For>
</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,
})
Link tabs
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
The initially selected tab value (uncontrolled).
The controlled selected tab value. Use with onValueChange.
orientation
"horizontal" | "vertical"
The orientation of the tab list. Defaults to "horizontal".
Whether tabs activate on focus or require an explicit selection. Defaults to "automatic".
Whether arrow key navigation wraps from the last to first tab. Defaults to true.
Whether clicking the active tab clears the selection.
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
| Key | Description |
|---|
ArrowRight / ArrowDown | Moves focus to the next tab trigger |
ArrowLeft / ArrowUp | Moves focus to the previous tab trigger |
Home | Moves focus to the first tab trigger |
End | Moves focus to the last tab trigger |
Enter / Space | Selects the focused tab (in manual activation mode) |
Tab | Moves focus into the active tab panel |