Expandable Tabs
An icon-only tab bar where the active tab springs open to reveal its label with a blur-in.
Trending collections picked for you.
vue
<script setup lang="ts">
import type { ExpandableTabItem, ExpandableTabSeparator } from '@/components/ui/expandable-tabs'
import { ExpandableTabs } from '@/components/ui/expandable-tabs'
import { Bell, Bookmark, Compass, House, Inbox, Settings, User } from 'lucide-vue-next'
import { computed, ref } from 'vue'
const tab = ref('discover')
const items: (ExpandableTabItem | ExpandableTabSeparator)[] = [
{ value: 'home', label: 'Home', icon: House },
{ value: 'discover', label: 'Discover', icon: Compass },
{ value: 'inbox', label: 'Inbox', icon: Inbox },
{ value: 'saved', label: 'Saved', icon: Bookmark },
{ separator: true },
{ value: 'alerts', label: 'Notifications', icon: Bell },
{ value: 'profile', label: 'Profile', icon: User },
{ value: 'settings', label: 'Settings', icon: Settings },
]
const blurbs: Record<string, string> = {
home: 'Your feed, freshly sorted.',
discover: 'Trending collections picked for you.',
inbox: '3 unread conversations.',
saved: '12 items saved for later.',
alerts: 'You’re all caught up.',
profile: 'Signed in as @ana.',
settings: 'Theme, privacy and connected apps.',
}
const blurb = computed(() => blurbs[tab.value])
</script>
<template>
<div class="flex flex-col items-center gap-6">
<ExpandableTabs v-model="tab" :items="items" label="Sections" />
<p class="m-0 text-sm text-muted-foreground" aria-live="polite">
{{ blurb }}
</p>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/expandable-tabs.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/expandable-tabs/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { Component, HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
export interface ExpandableTabItem {
value: string
label: string
icon: Component
/** Renders a link (only in `as="nav"`). */
href?: string
disabled?: boolean
}
export interface ExpandableTabSeparator {
separator: true
}
const props = withDefaults(defineProps<{
items: (ExpandableTabItem | ExpandableTabSeparator)[]
/** `tabs` → tablist semantics; `nav` → a navigation landmark with `aria-current`. */
as?: 'tabs' | 'nav'
/** Accessible name of the bar. */
label?: string
class?: HTMLAttributes['class']
/** Classes for the active (expanded) item. */
activeClass?: HTMLAttributes['class']
}>(), {
as: 'tabs',
})
const model = defineModel<string>()
const reduced = useReducedMotion()
const root = ref<HTMLElement>()
const isSeparator = (item: ExpandableTabItem | ExpandableTabSeparator): item is ExpandableTabSeparator => 'separator' in item
const tabs = computed(() => props.items.filter((i): i is ExpandableTabItem => !isSeparator(i)))
const nav = computed(() => props.as === 'nav')
if (model.value === undefined)
model.value = tabs.value.find(t => !t.disabled)?.value
const grow = { type: 'spring', stiffness: 420, damping: 34 } as const
const label = computed(() => ({
initial: { width: 0, opacity: 0, filter: reduced.value ? 'blur(0px)' : 'blur(6px)' },
animate: {
width: 'auto',
opacity: 1,
filter: 'blur(0px)',
transition: { width: grow, opacity: { duration: 0.25, delay: 0.05 }, filter: { duration: 0.3, delay: 0.05 } },
},
exit: {
width: 0,
opacity: 0,
filter: reduced.value ? 'blur(0px)' : 'blur(6px)',
transition: { width: grow, opacity: { duration: 0.12 }, filter: { duration: 0.12 } },
},
}))
function onKeydown(e: KeyboardEvent, item: ExpandableTabItem) {
if (nav.value)
return
const enabled = tabs.value.filter(t => !t.disabled)
const at = enabled.indexOf(item)
let next: ExpandableTabItem | undefined
if (e.key === 'ArrowRight')
next = enabled[(at + 1) % enabled.length]
else if (e.key === 'ArrowLeft')
next = enabled[(at - 1 + enabled.length) % enabled.length]
else if (e.key === 'Home')
next = enabled[0]
else if (e.key === 'End')
next = enabled.at(-1)
if (!next)
return
e.preventDefault()
model.value = next.value
root.value?.querySelector<HTMLElement>(`[data-value="${CSS.escape(next.value)}"]`)?.focus()
}
</script>
<template>
<component
:is="nav ? 'nav' : 'div'"
ref="root"
:role="nav ? undefined : 'tablist'"
:aria-label="props.label"
:class="cn('inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-2xl border bg-background p-1 text-foreground shadow-sm [scrollbar-width:none]', props.class)"
>
<template v-for="(item, index) in props.items" :key="isSeparator(item) ? `sep-${index}` : item.value">
<span v-if="isSeparator(item)" aria-hidden="true" class="mx-1 h-6 w-px shrink-0 bg-border" />
<component
:is="nav && item.href ? 'a' : 'button'"
v-else
:type="nav && item.href ? undefined : 'button'"
:href="nav && !item.disabled ? item.href : undefined"
:data-value="item.value"
:role="nav ? undefined : 'tab'"
:aria-selected="nav ? undefined : model === item.value"
:aria-current="nav && model === item.value ? 'page' : undefined"
:aria-label="item.label"
:title="model === item.value ? undefined : item.label"
:tabindex="nav ? undefined : model === item.value ? 0 : -1"
:disabled="nav && item.href ? undefined : item.disabled"
:aria-disabled="item.disabled || undefined"
:class="cn(
'inline-flex h-10 shrink-0 cursor-pointer items-center rounded-xl px-3 text-sm font-medium no-underline outline-none transition-colors duration-300 focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:pointer-events-none aria-disabled:opacity-40 [&_svg]:size-[18px] [&_svg]:shrink-0',
model === item.value
? cn('bg-accent text-accent-foreground', props.activeClass)
: 'text-muted-foreground hover:bg-muted/70 hover:text-foreground',
)"
@click="!item.disabled && (model = item.value)"
@keydown="onKeydown($event, item)"
>
<component :is="item.icon" aria-hidden="true" />
<AnimatePresence :initial="false">
<motion.span
v-if="model === item.value"
key="label"
aria-hidden="true"
class="overflow-hidden whitespace-nowrap"
:initial="label.initial"
:animate="label.animate"
:exit="label.exit"
>
<span class="block pl-2">{{ item.label }}</span>
</motion.span>
</AnimatePresence>
</component>
</template>
</component>
</template>ts
export { default as ExpandableTabs } from './ExpandableTabs.vue'
export type { ExpandableTabItem, ExpandableTabSeparator } from './ExpandableTabs.vue'Usage
ts
import { ExpandableTabs } from '@/components/ui/expandable-tabs'vue
<ExpandableTabs
v-model="section"
label="Sections"
:items="[
{ value: 'home', label: 'Home', icon: House },
{ value: 'inbox', label: 'Inbox', icon: Inbox },
{ separator: true },
{ value: 'settings', label: 'Settings', icon: Settings },
]"
/>As site navigation, pass as="nav" and an href per item: items render as links inside a <nav>, and the active one gets aria-current="page".
vue
<ExpandableTabs v-model="route" as="nav" label="Main" :items="[{ value: 'docs', label: 'Docs', icon: Book, href: '/docs' }]" />API
| Prop | Type | Default | Description |
|---|---|---|---|
items | (ExpandableTabItem | { separator: true })[] | — | { value, label, icon, href?, disabled? } or a separator. |
v-model | string | first enabled item | Active item value. |
as | 'tabs' | 'nav' | 'tabs' | Tablist semantics, or a <nav> with links. |
label | string | — | Accessible name of the bar. |
active-class | string | — | Classes for the expanded item. |
In tabs mode the bar has one tab stop and ← / → / Home / End move the selection. Every item keeps its label as its accessible name, so icon-only items are never anonymous.