Spring Sidebar
An app sidebar that springs between full width and an icon rail, with a gliding active indicator.
Workspace
Projects / Mobile app
vue
<script setup lang="ts">
import type { SpringSidebarItem } from '@/components/ui/spring-sidebar'
import { SpringSidebar } from '@/components/ui/spring-sidebar'
import { BarChart3, FolderKanban, Inbox, LayoutDashboard, Settings } from 'lucide-vue-next'
import { computed, ref } from 'vue'
const items: SpringSidebarItem[] = [
{ value: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ value: 'inbox', label: 'Inbox', icon: Inbox, badge: 4 },
{
value: 'projects',
label: 'Projects',
icon: FolderKanban,
children: [
{ value: 'projects/website', label: 'Website redesign' },
{ value: 'projects/mobile', label: 'Mobile app' },
{ value: 'projects/brand', label: 'Brand refresh' },
],
},
{ value: 'analytics', label: 'Analytics', icon: BarChart3 },
{ value: 'settings', label: 'Settings', icon: Settings },
]
const collapsed = ref(false)
const active = ref('projects/mobile')
const title = computed(() => {
for (const item of items) {
if (item.value === active.value)
return item.label
const child = item.children?.find(c => c.value === active.value)
if (child)
return `${item.label} / ${child.label}`
}
return ''
})
</script>
<template>
<div class="flex h-[480px] w-full max-w-2xl overflow-hidden rounded-2xl border bg-background shadow-sm">
<SpringSidebar v-model:collapsed="collapsed" v-model:active="active" :items="items">
<template #header="{ collapsed: rail }">
<div class="flex items-center gap-3 pl-[3px]">
<span class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-400 to-sky-500 text-sm font-bold text-white">
A
</span>
<span
class="whitespace-nowrap text-sm font-semibold transition-[opacity,filter] duration-200"
:class="rail ? 'opacity-0 blur-sm' : 'opacity-100'"
>Acme Studio</span>
</div>
</template>
</SpringSidebar>
<div class="flex min-w-0 flex-1 flex-col gap-4 p-6">
<p class="m-0 text-xs font-medium uppercase tracking-widest text-muted-foreground">
Workspace
</p>
<p class="m-0 truncate text-lg font-semibold">
{{ title }}
</p>
<div class="grid flex-1 grid-cols-2 grid-rows-2 gap-3">
<div v-for="n in 4" :key="n" class="rounded-xl border border-dashed bg-muted/40" />
</div>
</div>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/spring-sidebar.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/spring-sidebar/ (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, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
export interface SpringSidebarItem {
/** Unique id, used for `v-model:active`. */
value: string
label: string
icon?: Component
/** Render as a link. */
href?: string
/** Small count / tag; shrinks to a dot in rail mode. */
badge?: string | number
/** One level of nested items; the parent becomes a collapsible group. */
children?: SpringSidebarItem[]
}
const props = withDefaults(defineProps<{
items: SpringSidebarItem[]
/** Expanded width in px. */
width?: number
/** Rail (collapsed) width in px. */
railWidth?: number
/** Accessible name of the navigation landmark. */
label?: string
class?: HTMLAttributes['class']
}>(), {
width: 248,
railWidth: 64,
label: 'Main',
})
const collapsed = defineModel<boolean>('collapsed', { default: false })
const active = defineModel<string>('active')
const emit = defineEmits<{ select: [item: SpringSidebarItem] }>()
const uid = useId()
const indicatorId = `vx-sidebar-${uid}`
const nav = ref<HTMLElement | null>(null)
const reduced = useReducedMotion()
const spring = { type: 'spring', stiffness: 320, damping: 32, mass: 0.9 } as const
const glide = { type: 'spring', stiffness: 460, damping: 38 } as const
// Groups start open when they contain the active item.
const parentOf = (value?: string) => props.items.find(i => i.children?.some(c => c.value === value))
const openGroups = ref<string[]>(props.items.filter(i => i.children?.some(c => c.value === active.value)).map(i => i.value))
interface Row { item: SpringSidebarItem, level: 0 | 1, parent?: SpringSidebarItem }
const rows = computed<Row[]>(() => props.items.flatMap((item) => {
const row: Row = { item, level: 0 }
if (!item.children?.length || collapsed.value || !openGroups.value.includes(item.value))
return [row]
return [row, ...item.children.map(child => ({ item: child, level: 1 as const, parent: item }))]
}))
// When the active item is hidden inside a closed group (or the rail), the
// indicator rests on its parent instead.
const indicatorOn = computed(() => {
const parent = parentOf(active.value)
if (parent && (collapsed.value || !openGroups.value.includes(parent.value)))
return parent.value
return active.value
})
function toggleGroup(item: SpringSidebarItem, force?: boolean) {
const isOpen = openGroups.value.includes(item.value)
const next = force ?? !isOpen
if (next === isOpen)
return
openGroups.value = next ? [...openGroups.value, item.value] : openGroups.value.filter(v => v !== item.value)
}
function onSelect(row: Row) {
const { item } = row
if (item.children?.length) {
if (collapsed.value) {
collapsed.value = false
toggleGroup(item, true)
}
else {
toggleGroup(item)
}
return
}
active.value = item.value
emit('select', item)
}
// --- rail tooltips (teleported so the nav's overflow can't clip them) --------
const tip = ref<{ label: string, x: number, y: number } | null>(null)
function showTip(item: SpringSidebarItem, e: Event) {
if (!collapsed.value)
return
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
tip.value = { label: item.label, x: r.right + 12, y: r.top + r.height / 2 }
}
function hideTip() {
tip.value = null
}
watch(collapsed, hideTip)
// --- keyboard ------------------------------------------------------------------
function onKeydown(e: KeyboardEvent, row: Row) {
const els = [...(nav.value?.querySelectorAll<HTMLElement>('[data-vx-nav]') ?? [])]
const i = els.indexOf(e.currentTarget as HTMLElement)
let next: HTMLElement | undefined
if (e.key === 'ArrowDown')
next = els[(i + 1) % els.length]
else if (e.key === 'ArrowUp')
next = els[(i - 1 + els.length) % els.length]
else if (e.key === 'Home')
next = els[0]
else if (e.key === 'End')
next = els.at(-1)
else if (e.key === 'ArrowRight' && row.item.children?.length && !collapsed.value)
toggleGroup(row.item, true)
else if (e.key === 'ArrowLeft' && row.item.children?.length)
toggleGroup(row.item, false)
else if (e.key === 'ArrowLeft' && row.parent)
next = els.find(el => el.dataset.vxNav === row.parent!.value)
else
return
e.preventDefault()
next?.focus()
}
const labelState = computed(() => (collapsed.value
? { opacity: 0, x: -6, filter: reduced.value ? 'blur(0px)' : 'blur(4px)' }
: { opacity: 1, x: 0, filter: 'blur(0px)' }))
const labelTransition = computed(() => (collapsed.value
? { duration: 0.12 }
: { duration: 0.22, delay: 0.08 }))
</script>
<template>
<motion.aside
:class="cn(
'relative flex h-full shrink-0 flex-col overflow-hidden border-r bg-card text-card-foreground',
props.class,
)"
:initial="false"
:animate="{ width: collapsed ? props.railWidth : props.width }"
:transition="reduced ? { duration: 0 } : spring"
>
<div v-if="$slots.header" class="flex h-14 shrink-0 items-center px-3">
<slot name="header" :collapsed="collapsed" />
</div>
<nav
:id="`${uid}-nav`"
ref="nav"
:aria-label="props.label"
class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-2"
>
<AnimatePresence as="ul" :initial="false" class="m-0 flex list-none flex-col p-0">
<motion.li
v-for="row in rows"
:key="row.item.value"
:class="cn('py-px', row.level === 1 && 'ml-5 overflow-hidden border-l pl-2')"
:initial="row.level === 1 ? { height: 0, opacity: 0 } : { opacity: 0 }"
:animate="row.level === 1 ? { height: 'auto', opacity: 1 } : { opacity: 1 }"
:exit="row.level === 1 ? { height: 0, opacity: 0 } : { opacity: 0 }"
:transition="reduced ? { duration: 0 } : spring"
>
<component
:is="row.item.href && !row.item.children?.length ? 'a' : 'button'"
:href="row.item.children?.length ? undefined : row.item.href"
:type="row.item.href && !row.item.children?.length ? undefined : 'button'"
:data-vx-nav="row.item.value"
:aria-current="active === row.item.value ? 'page' : undefined"
:aria-expanded="row.item.children?.length ? !collapsed && openGroups.includes(row.item.value) : undefined"
:class="cn(
'relative isolate flex w-full items-center gap-3 rounded-lg text-left text-sm no-underline outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
row.level === 0 ? 'h-10 px-[11px]' : 'h-9 px-2.5 focus-visible:ring-inset',
indicatorOn === row.item.value ? 'font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)"
@click="onSelect(row)"
@keydown="onKeydown($event, row)"
@pointerenter="showTip(row.item, $event)"
@pointerleave="hideTip"
@focus="showTip(row.item, $event)"
@blur="hideTip"
>
<motion.span
v-if="indicatorOn === row.item.value"
aria-hidden="true"
:layout-id="indicatorId"
class="absolute inset-0 -z-10 rounded-lg bg-accent"
:transition="reduced ? { duration: 0 } : glide"
>
<span v-if="row.level === 0" class="absolute -left-3 inset-y-2 w-[3px] rounded-r-full bg-brand" />
</motion.span>
<span v-if="row.item.icon" class="relative flex size-[18px] shrink-0 items-center justify-center">
<component :is="row.item.icon" class="size-[18px]" aria-hidden="true" />
<motion.span
v-if="row.item.badge !== undefined"
aria-hidden="true"
class="absolute -right-1 -top-1 size-2 rounded-full bg-brand ring-2 ring-card"
:initial="false"
:animate="{ scale: collapsed ? 1 : 0 }"
:transition="spring"
/>
</span>
<motion.span
class="flex min-w-0 flex-1 items-center gap-2 whitespace-nowrap"
:initial="false"
:animate="labelState"
:transition="labelTransition"
>
<span class="truncate">{{ row.item.label }}</span>
<span
v-if="row.item.badge !== undefined"
class="ml-auto rounded-full bg-muted px-1.5 text-[11px] font-medium leading-5 text-muted-foreground"
>{{ row.item.badge }}</span>
<motion.svg
v-if="row.item.children?.length"
:class="cn('size-3.5 shrink-0 text-muted-foreground', row.item.badge === undefined && 'ml-auto')"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="false"
:animate="{ rotate: openGroups.includes(row.item.value) ? 90 : 0 }"
:transition="spring"
>
<path d="m9 18 6-6-6-6" />
</motion.svg>
</motion.span>
</component>
</motion.li>
</AnimatePresence>
</nav>
<div class="shrink-0 border-t px-3 py-3">
<slot name="footer" :collapsed="collapsed" />
<button
type="button"
:aria-controls="`${uid}-nav`"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
class="flex h-10 w-full items-center gap-3 rounded-lg px-[11px] text-sm text-muted-foreground outline-none transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="collapsed = !collapsed"
>
<motion.svg
class="size-[18px] shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
:initial="false"
:animate="{ rotate: collapsed ? 180 : 0 }"
:transition="spring"
>
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18" /><path d="m16 15-3-3 3-3" />
</motion.svg>
<motion.span class="whitespace-nowrap" :initial="false" :animate="labelState" :transition="labelTransition">
Collapse
</motion.span>
</button>
</div>
</motion.aside>
<Teleport to="body">
<AnimatePresence>
<motion.span
v-if="tip"
key="tip"
aria-hidden="true"
class="pointer-events-none fixed z-[95] -translate-y-1/2 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background shadow-lg"
:style="{ left: `${tip.x}px`, top: `${tip.y}px` }"
:initial="{ opacity: 0, x: -6, scale: 0.9 }"
:animate="{ opacity: 1, x: 0, scale: 1 }"
:exit="{ opacity: 0, x: -4, scale: 0.95, transition: { duration: 0.1 } }"
:transition="{ type: 'spring', stiffness: 520, damping: 30 }"
>
{{ tip.label }}
</motion.span>
</AnimatePresence>
</Teleport>
</template>ts
export { default as SpringSidebar } from './SpringSidebar.vue'
export type { SpringSidebarItem } from './SpringSidebar.vue'Usage
ts
import type { SpringSidebarItem } from '@/components/ui/spring-sidebar'
import { SpringSidebar } from '@/components/ui/spring-sidebar'
import { Inbox, LayoutDashboard, Settings } from 'lucide-vue-next'
const items: SpringSidebarItem[] = [
{ value: 'home', label: 'Dashboard', icon: LayoutDashboard, href: '/' },
{ value: 'inbox', label: 'Inbox', icon: Inbox, badge: 4 },
{ value: 'settings', label: 'Settings', icon: Settings, children: [
{ value: 'settings/profile', label: 'Profile' },
{ value: 'settings/billing', label: 'Billing' },
] },
]vue
<SpringSidebar v-model:collapsed="collapsed" v-model:active="active" :items="items">
<template #header="{ collapsed }">…logo…</template>
<template #footer="{ collapsed }">…account…</template>
</SpringSidebar>Collapsing springs the width down to an icon rail while the labels fade and blur out; badges turn into dots and hovering or focusing an icon shows a tooltip. In the rail, nested groups fold into their parent — the active indicator rests on the parent and clicking it expands the sidebar with the group open.
API
| Prop | Type | Default | Description |
|---|---|---|---|
items | SpringSidebarItem[] | — | { value, label, icon?, href?, badge?, children? }. |
v-model:collapsed | boolean | false | Rail mode. |
v-model:active | string | — | value of the current item (aria-current="page"). |
width | number | 248 | Expanded width (px). |
railWidth | number | 64 | Collapsed width (px). |
label | string | 'Main' | Accessible name of the <nav>. |
class | string | — | Classes for the <aside>. |
| Event | Payload | Description |
|---|---|---|
select | SpringSidebarItem | A leaf item was chosen. |
| Slot | Props | Description |
|---|---|---|
header | { collapsed } | Top row (logo / workspace). |
footer | { collapsed } | Above the collapse toggle (account, etc.). |
Keyboard: ↑/↓, Home/End move between items, →/← open and close groups (and ← jumps from a child to its parent).