Tabs
An accessible tabs component that provides keyboard interactions and ARIA attributes described in the WAI-ARIA Tabs Design Pattern. Tabs consist of a tab list with one or more visually separated tabs. Each tab has associated content, and only the selected tab’s content is shown.
Account
Make changes to your account here. Click save when you're done.
Password
Change your password here. After saving, you'll be logged out.
Account
Make changes to your account here. Click save when you're done.
Password
Change your password here. After saving, you'll be logged out.
Features
Install
Install the component from your command line.
Anatomy
Import all parts and piece them together.
<script setup lang="ts">import * as tabs from '@destyler/tabs'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId } from 'vue'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'},]
const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1'}))const api = computed(() => tabs.connect(state.value, send, normalizeProps))</script>
<template> <div v-bind="api.getRootProps()"> <div v-bind="api.getListProps()"> <button v-for="item in data" v-bind="api.getTriggerProps({ value: item.value })" :key="item.value" > {{ item.label }} </button> </div> <div v-for="item in data" v-bind="api.getContentProps({ value: item.value })" :key="item.value" > {{ item.content }} </div> </div></template>import { normalizeProps, useMachine } from '@destyler/react'import * as tabs from '@destyler/tabs'import { useId } from 'react'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'},]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1', }))
const api = tabs.connect(state, send, normalizeProps)
return ( <div {...api.getRootProps()}> <div {...api.getListProps()}> {data.map(item => ( <button key={item.value} {...api.getTriggerProps({ value: item.value })} > {item.label} </button> ))} </div> {data.map(item => ( <div key={item.value} {...api.getContentProps({ value: item.value })} > {item.content} </div> ))} </div> )}<script lang="ts"> import * as tabs from '@destyler/tabs' import { normalizeProps, useMachine } from '@destyler/svelte'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'}, ]
const id = $props.id() const [state, send] = useMachine(tabs.machine({ id, defaultValue: 'item-1' })) const api = $derived(tabs.connect(state, send, normalizeProps))</script>
<div {...api.getRootProps()}> <div {...api.getListProps()}> {#each data as item} <button {...api.getTriggerProps({ value: item.value })}> {item.label} </button> {/each} </div> {#each data as item} <div {...api.getContentProps({ value: item.value })}> { data.content } </div> {/each}</div>import { normalizeProps, useMachine } from '@destyler/solid'import * as tabs from '@destyler/tabs'import { createMemo, createUniqueId } from 'solid-js'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'}, ]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: createUniqueId(), defaultValue: 'item-1', }))
const api = createMemo(() => tabs.connect(state, send, normalizeProps))
return ( <div {...api().getRootProps()}> <div {...api().getListProps()}> {data.map(item => ( <button {...api().getTriggerProps({ value: item.value })}> {item.label} </button> ))} </div> {data.map(item => ( <div {...api().getContentProps({ value: item.value })}> {item.content} </div> ))} </div> )}Setting the selected tab
To set the initially selected tab,
pass the value property to the machine’s context.
const [state, send] = useMachine( tabs.machine({ defaultValue: "tab-1", }),)Subsequently, you can use the api.setValue function to set the selected tab.
Controlled usage
Tabs value can be uncontrolled or controlled. Prefer defaultValue for an uncontrolled seed. Passing value into machine(...) is presence-controlled — sync with onValueChange and adapter context. See Controllable State.
tabs.machine({ id: "1", defaultValue: "tab-1",})
const value: string | null = "tab-1" // your reactive statetabs.machine({ id: "1", value, onValueChange(details) { if (details.value === null) { // Accept clearing by syncing null through your adapter context. return } // Accept selection by syncing details.value through your adapter context. },})Migration: nullable value-change notifications
onValueChange now reports { value: null } when api.clearValue() clears a
selection, or when a selected tab is deselected with deselectable: true.
Deselection includes clicking the selected trigger and pressing Enter in
manual activation mode. ValueChangeDetails.value is now string | null to
match the selected-value API.
Previously, null notifications were suppressed and the callback type only
accepted string. Strict TypeScript consumers with an explicitly string-only
handler may need to widen its parameter and handle the null branch:
import type { ValueChangeDetails } from '@destyler/tabs'
function onValueChange({ value }: ValueChangeDetails): void { if (value === null) { console.log('No tab selected') return } console.log('Selected tab:', value)}For a controlled machine, this callback is a change request. The accepted
selection stays unchanged until the parent syncs value, including null,
through setContext or the adapter context. A parent can accept immediately,
accept later, or veto the request. Repeating a vetoed deselection can therefore
report null again; syncing the parent value does not echo another callback.
For an uncontrolled machine, the selection is already cleared when the
callback runs. Calling clearValue() when the accepted value is already null
remains a no-op. Continue to omit value and use defaultValue for uncontrolled
initial selection. At the raw machine layer, a present value: undefined key
still marks the value as controlled; this change does not alter initialization
or ownership rules.
Changing the orientation
The default orientation of the tabs is horizontal. To change the orientation,
set the orientation property in the machine’s context to "vertical".
const [state, send] = useMachine( tabs.machine({ orientation: "vertical", }),)Showing an indicator
To show an active indicator when a tab is selected,
you add the tabIndicatorProps object provided by the connect function.
<script setup lang="ts">import * as tabs from '@destyler/tabs'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId } from 'vue'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'},]
const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1'}))const api = computed(() => tabs.connect(state.value, send, normalizeProps))</script>
<template> <div v-bind="api.getRootProps()"> <div v-bind="api.getListProps()"> <button v-for="item in data" v-bind="api.getTriggerProps({ value: item.value })" :key="item.value" > {{ item.label }} </button> <div v-bind="api.getIndicatorProps()" /> </div> <div v-for="item in data" v-bind="api.getContentProps({ value: item.value })" :key="item.value" > {{ item.content }} </div> </div></template>import { normalizeProps, useMachine } from '@destyler/react'import * as tabs from '@destyler/tabs'import { useId } from 'react'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'},]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1', }))
const api = tabs.connect(state, send, normalizeProps)
return ( <div {...api.getRootProps()}> <div {...api.getListProps()}> {data.map(item => ( <button key={item.value} {...api.getTriggerProps({ value: item.value })} > {item.label} </button> ))} <div {...api.getIndicatorProps()} /> </div> {data.map(item => ( <div key={item.value} {...api.getContentProps({ value: item.value })} > {item.content} </div> ))} </div> )}<script lang="ts"> import * as tabs from '@destyler/tabs' import { normalizeProps, useMachine } from '@destyler/svelte'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'}, ]
const id = $props.id() const [state, send] = useMachine(tabs.machine({ id, defaultValue: 'item-1' })) const api = $derived(tabs.connect(state, send, normalizeProps))</script>
<div {...api.getRootProps()}> <div {...api.getListProps()}> {#each data as item} <button {...api.getTriggerProps({ value: item.value })}> {item.label} </button> {/each} <div {...api.getIndicatorProps()}></div> </div> {#each data as item} <div {...api.getContentProps({ value: item.value })}> { data.content } </div> {/each}</div>import { normalizeProps, useMachine } from '@destyler/solid'import * as tabs from '@destyler/tabs'import { createMemo, createUniqueId } from 'solid-js'
const data = [ { value: 'item-1', label: 'item-1'}, { value: 'item-2', label: 'item-2'}, { value: 'item-3', label: 'item-3'}, ]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: createUniqueId(), defaultValue: 'item-1', }))
const api = createMemo(() => tabs.connect(state, send, normalizeProps))
return ( <div {...api().getRootProps()}> <div {...api().getListProps()}> {data.map(item => ( <button {...api().getTriggerProps({ value: item.value })}> {item.label} </button> ))} <div {...api().getIndicatorProps()} /> </div> {data.map(item => ( <div {...api().getContentProps({ value: item.value })}> {item.content} </div> ))} </div> )}Disabling a tab
To disable a tab, set the disabled property in the getTriggerProps to true.
When a Tab is disabled, it is skipped during keyboard navigation and it is not clickable.
<script setup lang="ts">import * as tabs from '@destyler/tabs'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId } from 'vue'
const data = [ { value: 'item-1', label: 'item-1', disabled: false }, { value: 'item-2', label: 'item-2', disabled: true}, { value: 'item-3', label: 'item-3', disabled: false},]
const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1'}))const api = computed(() => tabs.connect(state.value, send, normalizeProps))</script>
<template> <div v-bind="api.getRootProps()"> <div v-bind="api.getListProps()"> <button v-for="item in data" v-bind="api.getTriggerProps({ value: item.value, disabled: item.disabled })" :key="item.value" > {{ item.label }} </button> </div> <div v-for="item in data" v-bind="api.getContentProps({ value: item.value })" :key="item.value" > {{ item.content }} </div> </div></template>import { normalizeProps, useMachine } from '@destyler/react'import * as tabs from '@destyler/tabs'import { useId } from 'react'
const data = [ { value: 'item-1', label: 'item-1', disabled: false }, { value: 'item-2', label: 'item-2', disabled: true}, { value: 'item-3', label: 'item-3', disabled: false},]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: useId(), defaultValue: 'item-1', }))
const api = tabs.connect(state, send, normalizeProps)
return ( <div {...api.getRootProps()}> <div {...api.getListProps()}> {data.map(item => ( <button key={item.value} {...api.getTriggerProps({ value: item.value, disabled: item.disabled })} > {item.label} </button> ))} </div> {data.map(item => ( <div key={item.value} {...api.getContentProps({ value: item.value })} > {item.content} </div> ))} </div> )}<script lang="ts"> import * as tabs from '@destyler/tabs' import { normalizeProps, useMachine } from '@destyler/svelte'
const data = [ { value: 'item-1', label: 'item-1', disabled: false }, { value: 'item-2', label: 'item-2', disabled: true}, { value: 'item-3', label: 'item-3', disabled: false}, ]
const id = $props.id() const [state, send] = useMachine(tabs.machine({ id, defaultValue: 'item-1' })) const api = $derived(tabs.connect(state, send, normalizeProps))</script>
<div {...api.getRootProps()}> <div {...api.getListProps()}> {#each data as item} <button {...api.getTriggerProps({ value: item.value, disabled: item.disabled })}> {item.label} </button> {/each} </div> {#each data as item} <div {...api.getContentProps({ value: item.value })}> { data.content } </div> {/each}</div>import { normalizeProps, useMachine } from '@destyler/solid'import * as tabs from '@destyler/tabs'import { createMemo, createUniqueId } from 'solid-js'
const data = [ { value: 'item-1', label: 'item-1', disabled: false }, { value: 'item-2', label: 'item-2', disabled: true}, { value: 'item-3', label: 'item-3', disabled: false},]
export default function Tabs() { const [state, send] = useMachine(tabs.machine({ id: createUniqueId(), defaultValue: 'item-1', }))
const api = createMemo(() => tabs.connect(state, send, normalizeProps))
return ( <div {...api().getRootProps()}> <div {...api().getListProps()}> {data.map(item => ( <button {...api().getTriggerProps({ value: item.value, disabled: item.disabled })}> {item.label} </button> ))} </div> {data.map(item => ( <div {...api().getContentProps({ value: item.value })}> {item.content} </div> ))} </div> )}Listening for events
-
onValueChange— Callback invoked when the selected tab changes. -
onFocusChange— Callback invoked when the focused tab changes.
const [state, send] = useMachine( tabs.machine({ onFocusChange(details) { // details => { value: string | null } console.log("focused tab:", details.value) }, onValueChange(details) { // details => { value: string | null } if (details.value === null) { console.log("no tab selected") return } console.log("selected tab:", details.value) }, }),)Manual tab activation
By default, the tab can be selected when the receive focus from either the keyboard or pointer interaction. This is called “automatic tab activation”.
The other approach is “manual tab activation” which means the tab is selected with the Enter key or by clicking on the tab.
const [state, send] = useMachine( tabs.machine({ activationMode: "manual", }),)Styling guide
Earlier, we mentioned that each Tabs part has a
data-partattribute added to them to select and style them in the DOM.
Selected state
When a tab is selected,
a data-selected attribute is added to the trigger and content elements.
[data-part="trigger"][data-state="active"] { /* Styles for selected tab */}
[data-part="content"][data-state="active"] { /* Styles for selected tab */}Disabled state
When a tab is disabled, a data-disabled attribute is added to the trigger element.
[data-part="trigger"][data-disabled] { /* Styles for disabled tab */}Focused state
When a tab is focused,
you the :focus or :focus-visible pseudo-class to style it.
[data-part="trigger"]:focus { /* Styles for focused tab */}When any tab is focused, the list is given a data-focus attribute.
[data-part="list"][data-focus] { /* Styles for when any tab is focused */}Orientation styles
All parts of the tabs component have the data-orientation attribute.
You can use this to set the style for the horizontal or vertical tabs.
[data-part="trigger"][data-orientation="horizontal"] { /* Styles for horizontal/vertical tabs */}
[data-part="root"][data-orientation="horizontal"] { /* Styles for horizontal/vertical root */}
[data-part="indicator"][data-orientation="horizontal"] { /* Styles for horizontal/vertical tab-indicator */}
[data-part="list"][data-orientation="horizontal"] { /* Styles for horizontal/vertical list */}Methods and Properties
Machine Context
The tabs machine exposes the following context properties:
Partial<{ root: string; trigger: string; list: string; content: string; indicator: string; }> IntlTranslations boolean string string "horizontal" | "vertical" "manual" | "automatic" (details: ValueChangeDetails) => void (details: FocusChangeDetails) => void boolean boolean (details: NavigateDetails) => void "ltr" | "rtl" string () => ShadowRoot | Node | Document Machine API
The tabs api exposes the following methods:
string string (value: string) => void () => void (value: string) => void () => void () => void (fromValue?: string) => void (fromValue?: string) => void (props: TriggerProps) => TriggerState Data Attributes
Root
data-scope data-part data-orientation data-focus List
data-scope data-part data-focus data-orientation Trigger
data-scope data-part data-orientation data-disabled data-value data-selected data-focus data-ssr Content
data-scope data-part data-selected data-orientation Indicator
data-scope data-part data-orientation Accessibility
Keyboard Interaction
Tab ArrowDown ArrowRight ArrowUp ArrowLeft Home End Enter + Space