Tabs
Overview
A set of layered sections of content—known as tab panels—that are displayed one at a time.
Features
- WAI-ARIA tabs pattern — the list renders as a
tablist, triggers asrole="tab", panels asrole="tabpanel"; the active tab carriesaria-selected, each trigger links its panel viaaria-controls/aria-labelledby, andaria-orientationreflects the layout direction. - Data-driven compact composition —
STabsdelegates item iteration, default trigger/content composition, and indicator rendering to the generic AriaTabsCompact<T>, which owns the ARIA wiring for every item. - Controlled or uncontrolled state —
modelValuesupportsv-model(controlled);defaultValueprovides uncontrolled usage backed byuseControllableState. - Two activation modes —
activationMode: 'automatic'activates a tab on focus (ARIA default);'manual'activates only on click /Enter/Space. - Full keyboard navigation — the
useRovingFocusGrouphook provides arrow-key (and Home/End) movement that skips disabled tabs;Enter/Spaceactivates the focused tab. - Presence-based content mounting —
unmountOnHide: true(default) unmounts inactive panels after the exit animation;falsekeeps every panel mounted buthidden;forceMountkeeps a panel in the DOM unconditionally. - Animated indicator — a sliding indicator tracks the active tab with CSS variables (
--vean-tabs-indicator-size/--vean-tabs-indicator-position), measured viaResizeObserverand re-positioned on value/direction changes; RTL offsets are mirrored automatically. - Horizontal and vertical layouts —
orientation: 'vertical'stacks the list and runs the indicator along the block axis. - Three customization slots —
trigger(scoped{ ...item, active }),content(scoped{ ...item, active }), andindicator. - Six visual variants —
size(xs…2xl),orientation(horizontal / vertical),shape(square / rounded),fill(full / auto), andenableIndicator(falls back to a solid active-trigger style) via thetabsVariantsscv()recipe. - Aria composition —
TabsRoot/TabsList/TabsTrigger/TabsContent/TabsIndicator/TabsCompactare exported from@vean/aria/tabsfor fully custom styled builds.
Usage
STabsnow delegates item iteration, default trigger/content composition, and indicator rendering to AriaTabsCompact. For unstyled, data-driven usage, importTabsCompactfrom@vean/aria/tabs.
Demos
Basic
Custom
API
Aria Layer
7Tabs<T>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
class | ClassValue | — | Additional class names applied to the root element. |
size | ThemeSize | — | Visual size of the component. |
ui | Partial<TabsUi> | — | Styled tabs ui slots, including `indicatorContent` for the compact indicator body. |
fill | TabsFill | — | Fill. |
shape | TabsShape | — | Shape of the component. |
items | T[] | — | Items rendered by the component. |
enableIndicator | boolean | — | Whether to enable indicator. |
listProps | TabsListProps | — | Properties forwarded to the list element. |
triggerProps | TabsTriggerProps | — | Properties forwarded to the trigger element. |
contentProps | TabsContentProps | — | Properties forwarded to the content element. |
indicatorProps | TabsIndicatorProps | — | Properties forwarded to the indicator element. |
modelValue | T['value'] | null | — | The controlled value of the tab to activate. Can be bind as `v-model`. |
defaultValue | T['value'] | null | — | The value of the tab that should be active when initially rendered. Use when you do not need to control the state
of the tabs |
activationMode | TabsActivationMode | automatic | Whether a tab is activated automatically (on focus) or manually (on click). |
unmountOnHide | boolean | `true` | When `true`, the element will be unmounted on closed state. |
dir | Direction | — | The direction of navigation between items. |
loop | boolean | false | Whether keyboard navigation should loop around |
orientation | DataOrientation | — | The orientation of the group. Mainly so arrow navigation is done accordingly (left & right vs. up & down) |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | Exclude<T, undefined> | Event handler called when the value changes |
Slots
| Slot Name | Parameters | Description |
|---|---|---|
trigger | T & { selected: boolean; } | Custom content for the trigger slot. |
content | T & { selected: boolean; } | Custom content for the content slot. |
indicator | — | Custom content for the indicator slot. |
TabsCompact<T>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Items rendered by the component. |
enableIndicator | boolean | — | Whether to enable indicator. |
listProps | TabsListProps | — | Properties forwarded to the list element. |
triggerProps | TabsTriggerProps | — | Properties forwarded to the trigger element. |
contentProps | TabsContentProps | — | Properties forwarded to the content element. |
indicatorProps | TabsIndicatorProps | — | Properties forwarded to the indicator element. |
modelValue | T['value'] | null | — | The controlled value of the tab to activate. Can be bind as `v-model`. |
defaultValue | T['value'] | null | — | The value of the tab that should be active when initially rendered. Use when you do not need to control the state
of the tabs |
activationMode | TabsActivationMode | automatic | Whether a tab is activated automatically (on focus) or manually (on click). |
unmountOnHide | boolean | `true` | When `true`, the element will be unmounted on closed state. |
dir | Direction | — | The direction of navigation between items. |
loop | boolean | false | Whether keyboard navigation should loop around |
orientation | DataOrientation | — | The orientation of the group. Mainly so arrow navigation is done accordingly (left & right vs. up & down) |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | Exclude<T, undefined> | Event handler called when the value changes |
Slots
| Slot Name | Parameters | Description |
|---|---|---|
trigger | T & { selected: boolean; } | Custom content for the trigger slot. |
content | T & { selected: boolean; } | Custom content for the content slot. |
indicator | — | Custom content for the indicator slot. |
TabsContent
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | DefinedValue | — | A unique value that associates the content with a trigger. |
forceMount | boolean | — | Used to force mounting when more control is needed.
Useful when controlling animation with Vue animation libraries. |
TabsIndicator
Props
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
TabsList
TabsRoot<T>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | T | — | The controlled value of the tab to activate. Can be bind as `v-model`. |
defaultValue | T | — | The value of the tab that should be active when initially rendered. Use when you do not need to control the state
of the tabs |
activationMode | TabsActivationMode | automatic | Whether a tab is activated automatically (on focus) or manually (on click). |
unmountOnHide | boolean | `true` | When `true`, the element will be unmounted on closed state. |
dir | Direction | — | The direction of navigation between items. |
loop | boolean | false | Whether keyboard navigation should loop around |
orientation | DataOrientation | — | The orientation of the group. Mainly so arrow navigation is done accordingly (left & right vs. up & down) |
Emits
| Emit Name | Parameters | Description |
|---|---|---|
update:modelValue | Exclude<T, undefined> | Event handler called when the value changes |
TabsTrigger
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | DefinedValue | — | A unique value that associates the trigger with a content. |
disabled | boolean | — | When `true`, prevents the user from interacting with the tab. |
asChild | boolean | — | Change the default rendered element for the one passed as a child, merging their props and behavior. |
as | AsTag | Component | 'div' | The element or component this component should render as. Can be overwrite by `asChild` |
Notes
Architecture and benchmark comparison
| Concern | VeanUI | shadcn-vue / Radix Tabs | Ant Design Tabs | Element Plus Tabs |
|---|---|---|---|---|
| Aria / styled separation | ✅ @vean/aria/tabs + scv() | ✅ Aria primitives | ❌ single package | ❌ single package |
| Data-driven compact API | ✅ generic TabsCompact<T> + items | ✅ TabList/Tab/TabPanel parts | ✅ config-driven (items) | ✅ config-driven |
| Controlled / uncontrolled | ✅ modelValue / defaultValue | ✅ modelValue / defaultValue | ✅ activeKey / defaultActiveKey | ✅ v-model |
| Activation mode | ✅ automatic / manual | ✅ | ❌ (always automatic) | ❌ (always automatic) |
| Keyboard navigation | ✅ roving focus (arrows/Home/End) + Enter/Space | ✅ roving focus | ✅ arrows / Home / End | ✅ arrows |
| Disabled tab skipping | ✅ roving focus skips disabled items | ✅ | ✅ | ✅ |
| Presence / forceMount content | ✅ unmountOnHide + forceMount + exit animation | ✅ forceMount | ❌ | ❌ |
| Animated indicator | ✅ CSS vars + ResizeObserver + RTL mirroring | ❌ (no built-in indicator) | ✅ ink bar (line type) | ❌ |
| Horizontal + vertical | ✅ orientation | ✅ | ✅ tabPosition | ✅ tab-position |
| Variants | ✅ size × orientation × shape × fill | ❌ (style your own) | ✅ type / size / tabBarGutter | ✅ type / size |
| Custom slots | ✅ trigger / content / indicator | ✅ part-level | ✅ label / children | ✅ label / icon |
| ARIA wiring (controls/labelledby) | ✅ automatic per item | ✅ automatic | ✅ | ✅ |
Runtime considerations
- Activation on focus — with
activationMode: 'automatic'(default), focusing a tab via keyboard (orArrowRight/ArrowLeft) activates it; with'manual'only clicking or pressingEnter/Spaceactivates. - Indicator measurement — the indicator positions itself against the active trigger after the list ref is attached, on value/direction changes, and on resize; it renders only after the first successful measurement.
- RTL positioning — in
dir: 'rtl'the indicator offset is mirrored (list.clientWidth - offsetLeft - offsetWidth) and the CSSrtl:-translate-x-…flips the translation, so the indicator stays anchored to the active tab. - Content presence — with
unmountOnHide: true, inactive panel content is removed after the exit animation; withfalseit stays mounted but carries thehiddenattribute;forceMountkeeps the panel in the DOM while still honoringhiddenfor inactive panels. - Controlled vs. uncontrolled — when
modelValueis provided, internal writes only emitupdate:modelValue; the DOM follows the prop, so external changes re-render automatically. - Disabled tabs — a disabled trigger keeps focusability only for roving focus (
data-disabled, not the nativedisabledattribute), so arrow navigation can skip it;mousedownand key activation are blocked. - ARIA ids — triggers and panels are linked with deterministic ids (
soybean-tabs-trigger-{value}/soybean-tabs-content-{value});aria-controlsis only emitted once the matching panel registers itself.
FAQ
How do I switch from automatic to manual activation?
Set activationMode="manual". In manual mode focusing a tab no longer activates it; users activate a tab by clicking or pressing Enter/Space. This follows the WAI-ARIA tabs pattern for panels that are slow to render.
Why doesn’t the indicator appear immediately?
The indicator needs a first measurement of the active trigger (offset + size). It renders right after the list element is attached or the value changes. In a real browser a ResizeObserver notification also triggers a re-measure, keeping it aligned on resize.
What does unmountOnHide control?
With true (default), switching tabs removes inactive panel content from the DOM after the exit animation — best for performance. With false, every panel stays mounted but inactive ones are hidden — best for preserving state (scroll positions, form inputs).
How is keyboard navigation handled?
The list is a roving-focus group: ArrowLeft/ArrowRight (or ArrowUp/ArrowDown in vertical mode) move focus, Home/End jump to the first/last tab, disabled tabs are skipped, and Enter/Space activates the focused tab.
Can I fully customize the tab rendering?
Yes — the trigger slot receives scoped { ...item, active } props, the content slot { ...item, active }, and indicator replaces the indicator body. For a completely custom structure, compose TabsRoot / TabsList / TabsTrigger / TabsContent / TabsIndicator from @vean/aria/tabs and inject styles via provideTabsUi (or STabs’s ui prop).
Do I need to wire aria-controls myself?
No. Each trigger automatically points aria-controls at its panel id (soybean-tabs-content-{value}) once the panel mounts, and each panel sets aria-labelledby back to its trigger — both stay in sync when items are added or removed.