Component · Navigation
Command Palette
A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.
Vue 3 app sidebar with sections, icons, badges, collapsible groups, an icon-only collapsed mode and a mobile off-canvas drawer. Tailwind CSS.
by Vueplayv1.0.0Updated 3 hours ago
The left-hand navigation most dashboards and SaaS apps need. Items are grouped into titled sections, can carry an icon and a badge (a count or a label like New), and can nest one level deep as collapsible groups that open automatically when they contain the active page.
On desktop the sidebar is sticky and can collapse to a narrow icon rail, with tooltips on hover and focus and a dot for items with badges. Below 1024px it turns into an off-canvas drawer opened from a built-in top bar: it traps focus, closes on Escape or backdrop click, locks page scroll and hands focus back to the menu button.
The active item, collapsed state and drawer state are all v-models, and each falls back to internal state when you don't bind it. Items render real links when you give them an href; otherwise listen to navigate and route however your app does. Swap the logo and the signed-in user with the brand and footer slots.
<script lang="ts">
export type SidebarIcon = 'home' | 'inbox' | 'folder' | 'chart' | 'users' | 'calendar' | 'settings' | 'help' | 'doc' | 'card' | 'bolt'
export interface SidebarItem {
id: string
label: string
icon?: SidebarIcon
/** Link target. Omit to handle navigation yourself via the `navigate` event. */
href?: string
badge?: string | number
/** Nested items render as a collapsible group. */
children?: SidebarItem[]
}
export interface SidebarSection {
title?: string
items: SidebarItem[]
}
// 24x24 outline icons, drawn with stroke-current.
const ICON_PATHS: Record<SidebarIcon, string> = {
home: 'M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6h-6v6H4a1 1 0 0 1-1-1v-9.5Z',
inbox: 'M3 13.5h4.5l1.5 3h6l1.5-3H21M5.2 5.6 3 13.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5.5l-2.2-7.9A2 2 0 0 0 16.9 4H7.1a2 2 0 0 0-1.9 1.6Z',
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z',
chart: 'M4 20V10m6 10V4m6 16v-7m4 7H3',
users: 'M16 20v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 18.5V20M10 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm10 9v-1.5a3.5 3.5 0 0 0-2.5-3.35M15.5 4.15a3.5 3.5 0 0 1 0 6.7',
calendar: 'M7 3v3m10-3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7.4-3a7.4 7.4 0 0 0-.1-1.2l2-1.6-2-3.4-2.4.9a7.5 7.5 0 0 0-2-1.2L14.5 3h-5l-.4 2.5a7.5 7.5 0 0 0-2 1.2l-2.4-.9-2 3.4 2 1.6a7.4 7.4 0 0 0 0 2.4l-2 1.6 2 3.4 2.4-.9a7.5 7.5 0 0 0 2 1.2l.4 2.5h5l.4-2.5a7.5 7.5 0 0 0 2-1.2l2.4.9 2-3.4-2-1.6c.07-.4.1-.8.1-1.2Z',
help: 'M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3m.1 4h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
doc: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Zm0 0v5h5M9 13h6m-6 4h4',
card: 'M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Zm0 3h18M7 15h3',
bolt: 'M13 3 4 14h7l-1 7 9-11h-7l1-7Z',
}
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue'
const props = withDefaults(defineProps<{
sections?: SidebarSection[]
/** Id of the active item (v-model:active). */
active?: string
/** Icon-only mode on desktop (v-model:collapsed). */
collapsed?: boolean
/** Mobile drawer visibility (v-model:open). */
open?: boolean
/** Product name shown next to the logo mark. */
brand?: string
/** Show the built-in mobile top bar with a menu button. */
mobileBar?: boolean
/** Show the collapse toggle at the bottom on desktop. */
collapsible?: boolean
}>(), {
sections: () => [
{ items: [
{ id: 'home', label: 'Overview', icon: 'home' },
{ id: 'inbox', label: 'Inbox', icon: 'inbox', badge: 12 },
{ id: 'calendar', label: 'Calendar', icon: 'calendar' },
] },
{ title: 'Workspace', items: [
{ id: 'projects', label: 'Projects', icon: 'folder', children: [
{ id: 'projects-active', label: 'Active', badge: 8 },
{ id: 'projects-archived', label: 'Archived' },
{ id: 'projects-templates', label: 'Templates' },
] },
{ id: 'reports', label: 'Reports', icon: 'chart', children: [
{ id: 'reports-revenue', label: 'Revenue' },
{ id: 'reports-retention', label: 'Retention', badge: 'New' },
] },
{ id: 'team', label: 'Team', icon: 'users' },
{ id: 'docs', label: 'Documents', icon: 'doc' },
] },
{ title: 'Account', items: [
{ id: 'billing', label: 'Billing', icon: 'card' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
{ id: 'help', label: 'Help center', icon: 'help' },
] },
],
active: undefined,
collapsed: false,
open: false,
brand: 'Quillfern',
mobileBar: true,
collapsible: true,
})
const emit = defineEmits<{
'update:active': [id: string]
'update:collapsed': [collapsed: boolean]
'update:open': [open: boolean]
navigate: [item: SidebarItem, event: MouseEvent]
}>()
defineSlots<{
/** Replace the logo mark and brand name. */
brand?: (props: { collapsed: boolean }) => unknown
/** Content pinned to the bottom, e.g. the signed-in user. */
footer?: (props: { collapsed: boolean }) => unknown
}>()
const uid = useId()
// Each v-model falls back to local state when it isn't bound.
const localActive = ref(props.active ?? props.sections[0]?.items[0]?.id ?? '')
const activeId = computed(() => props.active ?? localActive.value)
const localCollapsed = ref(props.collapsed)
watch(() => props.collapsed, v => { localCollapsed.value = v })
const localOpen = ref(props.open)
watch(() => props.open, v => { localOpen.value = v })
const isDesktop = ref(true)
let mq: MediaQueryList | null = null
const onMq = () => { isDesktop.value = !!mq?.matches; if (isDesktop.value) setOpen(false) }
onMounted(() => {
mq = window.matchMedia('(min-width: 1024px)')
onMq()
mq.addEventListener('change', onMq)
})
onBeforeUnmount(() => {
mq?.removeEventListener('change', onMq)
document.body.style.overflow = ''
})
/** Collapse only applies on desktop; the mobile drawer always shows labels. */
const isCollapsed = computed(() => localCollapsed.value && isDesktop.value)
function setCollapsed(v: boolean) {
localCollapsed.value = v
tooltip.value = null
emit('update:collapsed', v)
}
// ---- Groups ----
const containsActive = (item: SidebarItem): boolean => !!item.children?.some(c => c.id === activeId.value || containsActive(c))
const openGroups = reactive(new Set<string>())
watch(activeId, () => {
for (const s of props.sections) for (const i of s.items) if (containsActive(i)) openGroups.add(i.id)
}, { immediate: true })
function toggleGroup(item: SidebarItem) {
if (isCollapsed.value) { setCollapsed(false); openGroups.add(item.id); return }
if (openGroups.has(item.id)) openGroups.delete(item.id)
else openGroups.add(item.id)
}
function select(item: SidebarItem, e: MouseEvent) {
if (!item.href) e.preventDefault()
localActive.value = item.id
emit('update:active', item.id)
emit('navigate', item, e)
if (!isDesktop.value) setOpen(false)
}
// ---- Mobile drawer ----
const drawer = ref<HTMLElement | null>(null)
const menuButton = ref<HTMLButtonElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
function setOpen(v: boolean) {
if (localOpen.value === v) return
localOpen.value = v
emit('update:open', v)
}
watch(localOpen, async (v) => {
if (typeof document === 'undefined') return
document.body.style.overflow = v && !isDesktop.value ? 'hidden' : ''
await nextTick()
if (v) closeButton.value?.focus()
else if (!isDesktop.value) menuButton.value?.focus()
})
function onDrawerKeydown(e: KeyboardEvent) {
if (!localOpen.value || isDesktop.value) return
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); return }
if (e.key !== 'Tab' || !drawer.value) return
const focusable = Array.from(drawer.value.querySelectorAll<HTMLElement>('a[href], button:not([disabled])')).filter(el => el.offsetParent !== null)
const first = focusable[0], last = focusable.at(-1)
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus() }
}
// ---- Tooltips for icon-only mode ----
const tooltip = ref<{ label: string; top: number; left: number } | null>(null)
function showTip(e: Event, label: string) {
if (!isCollapsed.value) return
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
tooltip.value = { label, top: r.top + r.height / 2, left: r.right + 10 }
}
const hideTip = () => { tooltip.value = null }
const itemBase = 'group relative flex w-full items-center gap-3 rounded-lg text-sm font-medium transition focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-indigo-500'
function itemClass(item: SidebarItem, nested = false) {
const active = item.id === activeId.value || (isCollapsed.value && containsActive(item))
return [
itemBase,
isCollapsed.value ? 'size-10 justify-center' : nested ? 'py-1.5 pr-2 pl-11' : 'px-2.5 py-2',
active
? nested ? 'text-indigo-700 dark:text-white' : 'bg-indigo-50 text-indigo-700 dark:bg-white/10 dark:text-white'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
]
}
</script>
<template>
<!-- Mobile top bar -->
<div v-if="mobileBar" class="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-gray-200 bg-white/90 px-4 backdrop-blur lg:hidden dark:border-gray-800 dark:bg-gray-950/90">
<button
ref="menuButton"
type="button"
class="-ml-1.5 grid size-9 place-items-center rounded-lg text-gray-700 hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:text-gray-200 dark:hover:bg-gray-800"
:aria-expanded="localOpen"
:aria-controls="`${uid}-sidebar`"
aria-label="Open navigation"
@click="setOpen(true)"
>
<svg viewBox="0 0 24 24" class="size-5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16" /></svg>
</button>
<slot name="brand" :collapsed="false">
<span class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<span class="grid size-7 place-items-center rounded-lg bg-indigo-600 text-white" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-4 fill-current"><path d="M12 2.5 21 7.5v9L12 21.5 3 16.5v-9L12 2.5Zm0 4.2-5 2.8v5l5 2.8 5-2.8v-5l-5-2.8Z" /></svg>
</span>
{{ brand }}
</span>
</slot>
</div>
<!-- Backdrop -->
<Transition enter-from-class="opacity-0" leave-to-class="opacity-0" enter-active-class="transition-opacity duration-200" leave-active-class="transition-opacity duration-200">
<div v-if="localOpen" class="fixed inset-0 z-40 bg-gray-950/50 backdrop-blur-[2px] lg:hidden" aria-hidden="true" @click="setOpen(false)" />
</Transition>
<aside
:id="`${uid}-sidebar`"
ref="drawer"
class="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col border-r border-gray-200 bg-white transition-[translate,width] duration-200 ease-out lg:sticky lg:top-0 lg:z-auto lg:h-screen lg:max-w-none lg:shrink-0 lg:translate-x-0 dark:border-gray-800 dark:bg-gray-950"
:class="[localOpen ? 'translate-x-0 shadow-2xl lg:shadow-none' : '-translate-x-full', isCollapsed ? 'lg:w-[4.5rem]' : 'lg:w-64']"
:role="!isDesktop && localOpen ? 'dialog' : undefined"
:aria-modal="!isDesktop && localOpen ? 'true' : undefined"
:aria-label="!isDesktop ? 'Navigation' : undefined"
:inert="!isDesktop && !localOpen ? true : undefined"
@keydown="onDrawerKeydown"
>
<div class="flex h-16 shrink-0 items-center px-4" :class="isCollapsed ? 'justify-center' : 'justify-between'">
<slot name="brand" :collapsed="isCollapsed">
<a href="#" class="flex items-center gap-2.5 rounded-lg text-base font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:text-white" @click.prevent>
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-indigo-600 text-white shadow-sm shadow-indigo-600/30" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-4.5 fill-current"><path d="M12 2.5 21 7.5v9L12 21.5 3 16.5v-9L12 2.5Zm0 4.2-5 2.8v5l5 2.8 5-2.8v-5l-5-2.8Z" /></svg>
</span>
<span :class="isCollapsed && 'sr-only'">{{ brand }}</span>
</a>
</slot>
<button
v-if="!isDesktop"
ref="closeButton"
type="button"
class="grid size-9 place-items-center rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Close navigation"
@click="setOpen(false)"
>
<svg viewBox="0 0 24 24" class="size-5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<nav aria-label="Main" class="flex-1 overflow-y-auto overflow-x-hidden px-3 pb-4" :class="isCollapsed && 'flex flex-col items-center'">
<div v-for="(section, si) in sections" :key="si" class="w-full" :class="si > 0 && 'mt-6'">
<h2 v-if="section.title && !isCollapsed" :id="`${uid}-s${si}`" class="mb-1.5 px-2.5 text-[11px] font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">{{ section.title }}</h2>
<div v-else-if="section.title" class="mx-auto mb-3 h-px w-8 bg-gray-200 dark:bg-gray-800" role="separator" />
<ul :aria-labelledby="section.title && !isCollapsed ? `${uid}-s${si}` : undefined" class="space-y-0.5" :class="isCollapsed && 'flex flex-col items-center'">
<li v-for="item in section.items" :key="item.id">
<!-- Group -->
<template v-if="item.children?.length">
<button
type="button"
:class="itemClass(item)"
:aria-expanded="!isCollapsed && openGroups.has(item.id)"
:aria-controls="`${uid}-g-${item.id}`"
:aria-label="isCollapsed ? item.label : undefined"
@click="toggleGroup(item)"
@mouseenter="showTip($event, item.label)"
@focus="showTip($event, item.label)"
@mouseleave="hideTip"
@blur="hideTip"
>
<svg v-if="item.icon" viewBox="0 0 24 24" class="size-5 shrink-0 fill-none stroke-current" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="ICON_PATHS[item.icon]" /></svg>
<template v-if="!isCollapsed">
<span class="flex-1 truncate text-left">{{ item.label }}</span>
<svg viewBox="0 0 20 20" class="size-4 shrink-0 fill-current text-gray-400 transition-transform duration-200" :class="openGroups.has(item.id) && 'rotate-90'" aria-hidden="true"><path fill-rule="evenodd" d="M7.2 14.8a.75.75 0 0 1 0-1.06L10.94 10 7.2 6.26a.75.75 0 1 1 1.06-1.06l4.27 4.27a.75.75 0 0 1 0 1.06L8.26 14.8a.75.75 0 0 1-1.06 0Z" clip-rule="evenodd" /></svg>
</template>
</button>
<ul v-show="!isCollapsed && openGroups.has(item.id)" :id="`${uid}-g-${item.id}`" class="relative mt-0.5 space-y-0.5 before:absolute before:inset-y-1 before:left-[1.35rem] before:w-px before:bg-gray-200 dark:before:bg-gray-800">
<li v-for="child in item.children" :key="child.id">
<a
:href="child.href ?? '#'"
:class="itemClass(child, true)"
:aria-current="child.id === activeId ? 'page' : undefined"
@click="select(child, $event)"
>
<span v-if="child.id === activeId" class="absolute top-1/2 left-[1.3rem] h-4 w-0.5 -translate-y-1/2 rounded-full bg-indigo-600 dark:bg-indigo-400" aria-hidden="true" />
<span class="flex-1 truncate">{{ child.label }}</span>
<span v-if="child.badge !== undefined" class="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-300">{{ child.badge }}</span>
</a>
</li>
</ul>
</template>
<!-- Link -->
<a
v-else
:href="item.href ?? '#'"
:class="itemClass(item)"
:aria-current="item.id === activeId ? 'page' : undefined"
:aria-label="isCollapsed ? (item.badge !== undefined ? `${item.label} (${item.badge})` : item.label) : undefined"
@click="select(item, $event)"
@mouseenter="showTip($event, item.label)"
@focus="showTip($event, item.label)"
@mouseleave="hideTip"
@blur="hideTip"
>
<svg v-if="item.icon" viewBox="0 0 24 24" class="size-5 shrink-0 fill-none stroke-current" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="ICON_PATHS[item.icon]" /></svg>
<template v-if="!isCollapsed">
<span class="flex-1 truncate">{{ item.label }}</span>
<span
v-if="item.badge !== undefined"
class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
:class="item.id === activeId ? 'bg-indigo-600 text-white dark:bg-indigo-500' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300'"
>{{ item.badge }}</span>
</template>
<span v-else-if="item.badge !== undefined" class="absolute top-1.5 right-1.5 size-2 rounded-full bg-indigo-500 ring-2 ring-white dark:ring-gray-950" aria-hidden="true" />
</a>
</li>
</ul>
</div>
</nav>
<div class="shrink-0 border-t border-gray-200 p-3 dark:border-gray-800">
<slot name="footer" :collapsed="isCollapsed">
<div class="flex items-center gap-3 rounded-lg p-1.5" :class="isCollapsed && 'justify-center'">
<span class="grid size-8 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-xs font-semibold text-white" aria-hidden="true">AL</span>
<div v-if="!isCollapsed" class="min-w-0 text-sm">
<p class="truncate font-medium text-gray-900 dark:text-white">Ada Lindgren</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">ada@quillfern.test</p>
</div>
</div>
</slot>
<button
v-if="collapsible && isDesktop"
type="button"
class="mt-2 flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
:class="isCollapsed && 'justify-center'"
:aria-label="isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
@click="setCollapsed(!isCollapsed)"
>
<svg viewBox="0 0 24 24" class="size-5 shrink-0 fill-none stroke-current transition-transform" :class="isCollapsed && 'rotate-180'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v14H4zM9 5v14M15.5 10 13.5 12l2 2" /></svg>
<span v-if="!isCollapsed">Collapse</span>
</button>
</div>
</aside>
<Teleport to="body">
<div
v-if="tooltip"
role="tooltip"
class="pointer-events-none fixed z-[60] -translate-y-1/2 rounded-md bg-gray-900 px-2 py-1 text-xs font-medium whitespace-nowrap text-white shadow-lg dark:bg-gray-700"
:style="{ top: `${tooltip.top}px`, left: `${tooltip.left}px` }"
>
{{ tooltip.label }}
</div>
</Teleport>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| sections | SidebarSection[] | Main, Workspace, Account | Navigation sections. Each has an optional title and items (id, label, icon, href, badge, children). |
| active | string | undefined (first item) | Id of the active item. Use with v-model:active. |
| collapsed | boolean | false | Icon-only mode on desktop. Use with v-model:collapsed. |
| open | boolean | false | Whether the mobile drawer is open. Use with v-model:open. |
| brand | string | 'Quillfern' | Product name next to the logo mark. |
| mobileBar | boolean | true | Render the built-in mobile top bar with the menu button. Turn off if your header opens the drawer via v-model:open. |
| collapsible | boolean | true | Show the collapse toggle at the bottom of the sidebar on desktop. |
| Event | Payload | Description |
|---|---|---|
| update:active | (id: string) | An item was selected. |
| update:collapsed | (collapsed: boolean) | The collapse toggle was used, or a group was opened from the icon rail. |
| update:open | (open: boolean) | The mobile drawer opened or closed. |
| navigate | (item: SidebarItem, event: MouseEvent) | An item was clicked. Items without href have the default link action prevented. |
| Slot | Description |
|---|---|
| brand | Replace the logo mark and product name. Receives { collapsed }. |
| footer | Content pinned to the bottom, such as the signed-in user. Receives { collapsed }. |
Component · Navigation
A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.
Component · Navigation
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
Component · Navigation
A flexible link component that can act as a router-link for internal navigation or an href for external URLs. Can be used on its own or inside a Navigator.
Component · Navigation
Vue 3 pagination bar with v-model:page, smart ellipsis, prev/next, a page-size picker, compact mobile view and a “Showing X–Y of Z” summary. Tailwind CSS.