Animated Tabs
A set of tab panels displayed one at a time. A faithful port of the Nuxt UI v4 tabs — same props, same slots, same theme — with an indicator that slides between triggers instead of snapping.
Installation
npx raya-ui@latest add animated-tabsFile Structure
Usage
Items
Pass an array of objects. Each one becomes a trigger, and — unless `content` is `false` — a panel.
<script setup lang="ts">
import { AnimatedTabs, type TabsItem } from '@/components/ui/animated-tabs'
import { User, Lock } from '@lucide/vue'
const items: TabsItem[] = [
{ label: 'Account', icon: User, content: 'This is the account content.' },
{ label: 'Password', icon: Lock, content: 'This is the password content.' }
]
</script>
<template>
<AnimatedTabs :items="items" class="w-full" />
</template>Content
Set `content` to `false` to render the triggers on their own — useful when the tabs drive something outside the component, like a code block header.
<AnimatedTabs :content="false" :items="items" class="w-full" />Unmount
Inactive panels are removed from the DOM by default. Set `unmount-on-hide` to `false` to keep them mounted and preserve their state.
<AnimatedTabs :unmount-on-hide="false" :items="items" class="w-full" />Color
The color drives the indicator fill, the active label and the focus outline.
<AnimatedTabs color="neutral" :content="false" :items="items" class="w-full" />Variant
`pill` slides a filled rectangle behind the active trigger. `link` draws a thin line along the edge of the list.
<AnimatedTabs color="neutral" variant="link" :content="false" :items="items" class="w-full" />Size
Sizes scale trigger padding, font size and icon size at once.
<AnimatedTabs size="xl" variant="pill" :content="false" :items="items" class="w-full" />Orientation
Vertical tabs stack the triggers and move the indicator to the vertical edge. The panels sit beside the list.
<AnimatedTabs orientation="vertical" variant="pill" :content="false" :items="items" class="w-full" />Control the active item
Use `v-model` (or `default-value`) with the `value` of an item. When an item has no `value`, it is identified by its index **as a string**. Use `value-key` to match on a different key.
<script setup lang="ts">
import { ref } from 'vue'
const active = ref('0')
</script>
<template>
<AnimatedTabs v-model="active" :content="false" :items="items" class="w-full" />
</template>With route query
Drive the active tab from a URL query parameter by giving each item a `value` and binding a writable computed.
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const items = [
{ label: 'Account', icon: User, value: 'account' },
{ label: 'Password', icon: Lock, value: 'password' }
]
const active = computed({
get: () => (route.query.tab as string) || 'account',
// The hash keeps the page from scrolling to the top
set: tab => router.push({ query: { tab }, hash: '#with-route-query' })
})
</script>
<template>
<AnimatedTabs v-model="active" :content="false" :items="items" class="w-full" />
</template>With content slot
The `#content` slot replaces the panel of every item.
<AnimatedTabs :items="items" class="w-full">
<template #content="{ item }">
<p>This is the {{ item.label }} tab.</p>
</template>
</AnimatedTabs>With custom slot
Give an item a `slot` name to render just that panel yourself.
<script setup lang="ts">
const items = [
{ label: 'Account', icon: User, slot: 'account' },
{ label: 'Password', icon: Lock, slot: 'password' }
]
</script>
<template>
<AnimatedTabs :items="items" variant="link" :ui="{ trigger: 'grow' }" class="gap-4 w-full">
<template #account="{ item }">
<p class="text-muted mb-4">Make changes to your account here.</p>
</template>
<template #password="{ item }">
<p class="text-muted mb-4">Change your password here.</p>
</template>
</AnimatedTabs>
</template>With bottom tab bar
The `ui` prop reshapes the tabs into a mobile-style bottom bar with stacked icons and small labels.
<AnimatedTabs
:items="items"
:content="false"
:ui="{
list: 'justify-around w-full',
trigger: 'grow flex-col gap-1 py-1',
label: 'text-[10px]/3'
}"
class="w-full"
/>Badges and avatars
An item can carry a trailing badge, and an avatar in place of an icon.
<script setup lang="ts">
const items = [
{ label: 'Inbox', icon: Inbox, badge: 12 },
{ label: 'Team', avatar: { src: 'https://github.com/nuxt.png' } }
]
</script>
<template>
<AnimatedTabs :items="items" :content="false" class="w-full" />
</template>CSS Variables
The tabs paint themselves entirely from these tokens, so they inherit your theme instead of hard-coding colors. Raw values live on :root and .dark; the @theme inline block maps them onto Tailwind's utility namespaces. That split is what lets text-inverted and bg-inverted resolve to opposite colors while both still accept opacity modifiers. This is the same set Raya Button installs — if you already have it, there is nothing new to add.
--ui-text-mutedtext-mutedThe resting color of an inactive trigger.
--ui-texttext-defaultThe color an inactive trigger takes on hover.
--ui-text-highlightedtext-highlightedThe active trigger label in the neutral link variant.
--ui-text-invertedtext-invertedThe active trigger label sitting on the neutral pill indicator.
--ui-bg-elevatedbg-elevatedThe track behind the pill variant, and the badge fill.
--ui-bg-invertedbg-invertedThe indicator fill for the neutral color.
--ui-borderborder-defaultThe edge line of the link variant.
--ui-border-accentedring-accentedThe badge ring.
--ui-border-invertedoutline-invertedThe focus outline for the neutral color.
--ui-text-dimmedtext-dimmedPart of the shared text scale; not painted by this component but installed with it.
--ui-text-tonedtext-tonedPart of the shared text scale; not painted by this component but installed with it.
--ui-bg / --ui-bg-muted / --ui-bg-accentedbg-default / bg-muted / bg-accentedThe rest of the shared surface scale.
--color-primary-contenttext-primary-contentThe active label on a primary pill indicator. Maps to `--primary-foreground`.
--color-secondary-contenttext-secondary-contentThe active label on a secondary pill indicator. Maps to `--secondary-foreground`.
--success / --success-foregroundbg-success / text-success-contentThe success indicator and its active label color.
--info / --info-foregroundbg-info / text-info-contentThe info indicator and its active label color.
--warning / --warning-foregroundbg-warning / text-warning-contentThe warning indicator and its active label color.
--error / --error-foregroundbg-error / text-error-contentThe error indicator and its active label color. Maps to `--destructive` by default.
text-primary-content, text-success-content, …) rather than Nuxt UI's single text-inverted, matching how Raya Button handles solid variants. The neutral color still uses text-inverted, which is already correct on bg-inverted. API Reference
Props
itemsTabsItem[][]The tabs to render. See the item shape below.
color"primary" | "secondary" | "success" | "info" | "warning" | "error" | "neutral""primary"The semantic color of the indicator and the active trigger.
variant"pill" | "link""pill"A filled pill that slides behind the active trigger, or a thin line along the edge of the list.
size"xs" | "sm" | "md" | "lg" | "xl""md"Drives trigger padding, font size and icon size together.
orientation"horizontal" | "vertical""horizontal"Lays the triggers out in a row or a column, moving the indicator to the matching edge.
contentbooleantrueSet to `false` to render the triggers without any panels.
model-valuestring | number—The value of the active tab. Bindable with `v-model`. Compared as a string.
default-valuestring | numberfirst itemThe tab active on first render, when the value is not controlled.
value-keystring"value"Which key identifies an item. Supports dot paths. Items without it fall back to their index as a string.
label-keystring"label"Which key holds the label of an item. Supports dot paths.
activation-mode"automatic" | "manual""automatic"Whether keyboard focus activates a tab immediately or only on click/Enter.
unmount-on-hidebooleantrueSet to `false` to keep inactive panels mounted in the DOM.
asComponent | string"div"The element or component the root renders as.
classstring—Merged onto the root slot.
ui{ root?, list?, indicator?, trigger?, leadingIcon?, leadingAvatar?, label?, trailingBadge?, content? }—Override the classes of any individual slot.
Item
The shape of each entry in items. Every property is optional.
labelstringThe text of the trigger.
iconComponent | stringLeading icon. Accepts a component (any @lucide/vue icon) or a CSS icon class such as `i-lucide-user`.
avatar{ src?, alt?, text?, loading? }Leading avatar, shown when there is no icon. Falls back to `text` initials.
badgestring | number | { label?, class? }Trailing badge, sized to the tab.
contentstringPanel text, used when no matching slot is provided.
valuestring | numberIdentifies the item. Defaults to its index as a string.
disabledbooleanPrevents the tab from being activated.
slotstringRenders this item's panel through the slot of that name.
classstringMerged onto this item's trigger.
ui{ trigger?, leadingIcon?, leadingAvatar?, label?, trailingBadge?, content? }Per-item slot class overrides.
Slots
leading{ item, index }Replaces the leading icon or avatar of a trigger.
default{ item, index }Replaces the label of a trigger.
trailing{ item, index }Replaces the trailing badge of a trigger.
content{ item, index }The panel of every item that has no `slot` of its own.
#{{ item.slot }}{ item, index }The panel of one specific item, named by its `slot` property.
list-leading—Rendered inside the list, before the first trigger.
list-trailing—Rendered inside the list, after the last trigger.
Emits & expose
update:modelValueEmitted with the value of the newly active tab.
triggersRefExposed on a template ref: the array of trigger component instances, in item order.
Theme slots
The theme lives in variants.ts as a tailwind-variants definition. Each slot can be overridden per instance with the ui prop, per item with item.ui, or globally by editing that file.
rootThe wrapper around the list and the panels.
listThe trigger container — the pill background or the bordered edge.
indicatorThe element that slides between triggers, positioned from the `--reka-tabs-indicator-*` variables.
triggerEach tab button.
leadingIconThe leading icon of a trigger.
leadingAvatarThe leading avatar image or initials.
leadingAvatarSizeThe size class applied to the avatar for each tab size.
labelThe `<span>` around a trigger label.
trailingBadgeThe trailing badge of a trigger.
trailingBadgeSizeThe size class applied to the badge.
contentEach panel.
<script setup lang="ts">
import { ref } from 'vue'
import { AnimatedTabs, type TabsItem } from '@/components/ui/animated-tabs'
import { User, Lock, Sliders } from '@lucide/vue'
const active = ref('account')
const items: TabsItem[] = [
{ value: 'account', label: 'Account', icon: User, content: '…' },
{ value: 'security', label: 'Password', icon: Lock, content: '…' },
{ value: 'preferences', label: 'Preferences', icon: Sliders, content: '…' }
]
</script>
<template>
<AnimatedTabs
:items="items"
v-model="active"
class="w-full"
/>
</template>