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.
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
by Vueplayv1.0.0Updated 2 hours ago
A menu button for account menus, row actions and export options. It follows the WAI-ARIA menu button pattern: Enter, Space or the arrow keys open it, arrow keys, Home and End move between items, typing a letter jumps to a matching item, and Escape closes it and puts focus back on the button.
Items are plain objects with a label and optional icon, shortcut hint, disabled or danger flag, mixed with separators and small headings. Disabled items are skipped by the keyboard. Clicking outside or tabbing away closes the menu, and it flips above the button automatically when there isn't room below.
Use the default button with a chevron, the compact ⋯ icon variant for table rows, or replace the button content entirely with the button slot. The header slot is handy for a "Signed in as" block. Listen for select to handle the chosen item.
<script lang="ts">
export interface MenuItem {
type?: 'item'
id: string
label: string
/** SVG path data for a 20×20 solid icon. */
icon?: string
/** Shortcut hint shown on the right, e.g. '⌘D'. Display only. */
shortcut?: string
disabled?: boolean
/** Styles the item as destructive. */
danger?: boolean
}
export interface MenuSeparator {
type: 'separator'
}
export interface MenuHeading {
type: 'heading'
label: string
}
export type MenuEntry = MenuItem | MenuSeparator | MenuHeading
/** Small 20×20 solid icons used by the default items. */
export const menuIcons = {
pencil: 'M14.1 2.9a2.1 2.1 0 0 1 3 3l-9.4 9.4a2 2 0 0 1-.9.52l-3.3.95a.6.6 0 0 1-.74-.74l.95-3.3a2 2 0 0 1 .52-.9l9.4-9.4Zm-1.06 2.12-1.4 1.4 1.94 1.94 1.4-1.4-1.94-1.94Z',
copy: 'M7 3.5A1.5 1.5 0 0 1 8.5 2h7A1.5 1.5 0 0 1 17 3.5v9a1.5 1.5 0 0 1-1.5 1.5H14V7.5A2.5 2.5 0 0 0 11.5 5H7V3.5ZM3 7.5A1.5 1.5 0 0 1 4.5 6h7A1.5 1.5 0 0 1 13 7.5v9a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 16.5v-9Z',
share: 'M13 4.5a2.5 2.5 0 1 1 .7 1.74L7.9 9.1a2.5 2.5 0 0 1 0 1.8l5.8 2.86a2.5 2.5 0 1 1-.66 1.35l-5.8-2.87a2.5 2.5 0 1 1 0-4.48l5.8-2.87A2.5 2.5 0 0 1 13 4.5Z',
archive: 'M2 4a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4Zm1 4.5h14V15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8.5ZM7.75 10a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-4.5Z',
download: 'M10 2.75a.75.75 0 0 1 .75.75v7.19l2.22-2.22a.75.75 0 1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-3.5-3.5a.75.75 0 1 1 1.06-1.06l2.22 2.22V3.5a.75.75 0 0 1 .75-.75ZM3.5 13a.75.75 0 0 1 .75.75v1.5c0 .14.11.25.25.25h11a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 15.5 17h-11a1.75 1.75 0 0 1-1.75-1.75v-1.5A.75.75 0 0 1 3.5 13Z',
trash: 'M8.5 2a1.5 1.5 0 0 0-1.5 1.5V4H4a.75.75 0 0 0 0 1.5h.3l.8 10.1A2.5 2.5 0 0 0 7.6 18h4.8a2.5 2.5 0 0 0 2.5-2.4l.8-10.1h.3a.75.75 0 0 0 0-1.5h-3v-.5A1.5 1.5 0 0 0 11.5 2h-3Zm0 6a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 8.5 8Zm3 0a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 11.5 8Z',
user: 'M10 9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm-6.5 8.25a6.5 6.5 0 0 1 13 0 .75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z',
cog: 'M8.3 2.2a1 1 0 0 1 1-.7h1.4a1 1 0 0 1 1 .7l.37 1.3c.4.16.77.37 1.1.62l1.32-.33a1 1 0 0 1 1.1.47l.7 1.22a1 1 0 0 1-.13 1.17l-.95.98a5 5 0 0 1 0 1.27l.95.98a1 1 0 0 1 .13 1.17l-.7 1.22a1 1 0 0 1-1.1.47l-1.32-.33c-.33.25-.7.46-1.1.62l-.37 1.3a1 1 0 0 1-1 .7H9.3a1 1 0 0 1-1-.7l-.37-1.3a5 5 0 0 1-1.1-.62l-1.32.33a1 1 0 0 1-1.1-.47l-.7-1.22a1 1 0 0 1 .13-1.17l.95-.98a5 5 0 0 1 0-1.27l-.95-.98a1 1 0 0 1-.13-1.17l.7-1.22a1 1 0 0 1 1.1-.47l1.32.33c.33-.25.7-.46 1.1-.62l.37-1.3ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
logout: 'M3 4.25A2.25 2.25 0 0 1 5.25 2h5.5A2.25 2.25 0 0 1 13 4.25v1a.75.75 0 0 1-1.5 0v-1a.75.75 0 0 0-.75-.75h-5.5a.75.75 0 0 0-.75.75v11.5c0 .41.34.75.75.75h5.5a.75.75 0 0 0 .75-.75v-1a.75.75 0 0 1 1.5 0v1A2.25 2.25 0 0 1 10.75 18h-5.5A2.25 2.25 0 0 1 3 15.75V4.25Zm11.22 2.47a.75.75 0 0 1 1.06 0l2.75 2.75a.75.75 0 0 1 0 1.06l-2.75 2.75a.75.75 0 1 1-1.06-1.06l1.47-1.47H8.75a.75.75 0 0 1 0-1.5h6.94l-1.47-1.47a.75.75 0 0 1 0-1.06Z',
} as const
export const defaultMenuItems: MenuEntry[] = [
{ id: 'edit', label: 'Edit', icon: menuIcons.pencil, shortcut: '⌘E' },
{ id: 'duplicate', label: 'Duplicate', icon: menuIcons.copy, shortcut: '⌘D' },
{ id: 'share', label: 'Share', icon: menuIcons.share },
{ type: 'separator' },
{ id: 'download', label: 'Download PDF', icon: menuIcons.download },
{ id: 'archive', label: 'Archive', icon: menuIcons.archive, disabled: true },
{ type: 'separator' },
{ id: 'delete', label: 'Delete', icon: menuIcons.trash, danger: true, shortcut: '⌫' },
]
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
const props = withDefaults(defineProps<{
items?: MenuEntry[]
/** Button text, or the accessible label when `variant` is 'icon'. */
label?: string
/** 'button' shows the label with a chevron; 'icon' is a compact ⋯ button. */
variant?: 'button' | 'icon'
/** Horizontal alignment of the menu relative to the button. */
align?: 'start' | 'end'
/** Tailwind width class for the menu panel. */
width?: string
disabled?: boolean
}>(), {
items: () => defaultMenuItems,
label: 'Options',
variant: 'button',
align: 'start',
width: 'w-56',
disabled: false,
})
const emit = defineEmits<{
select: [item: MenuItem]
open: []
close: []
}>()
defineSlots<{
/** Custom button content. */
button?: (props: { open: boolean }) => unknown
/** Content above the items, e.g. "Signed in as". */
header?: () => unknown
}>()
const uid = useId()
const buttonId = `${uid}-button`
const menuId = `${uid}-menu`
const root = ref<HTMLElement | null>(null)
const button = ref<HTMLButtonElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const open = ref(false)
const activeId = ref<string | null>(null)
const placeAbove = ref(false)
const resolvedAlign = ref<'start' | 'end'>(props.align)
const isItem = (e: MenuEntry): e is MenuItem => e.type === undefined || e.type === 'item'
const enabled = computed(() => props.items.filter(isItem).filter((i) => !i.disabled))
function focusItem(id: string | null | undefined) {
if (!id) return
activeId.value = id
menu.value?.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)?.focus()
}
async function openMenu(focus: 'first' | 'last' | 'none' = 'first') {
if (props.disabled) return
if (!open.value) {
open.value = true
emit('open')
}
await nextTick()
updatePlacement()
if (focus === 'first') focusItem(enabled.value[0]?.id)
else if (focus === 'last') focusItem(enabled.value[enabled.value.length - 1]?.id)
else menu.value?.focus()
}
function closeMenu(returnFocus = true) {
if (!open.value) return
open.value = false
activeId.value = null
emit('close')
if (returnFocus) button.value?.focus()
}
function choose(item: MenuItem) {
if (item.disabled) return
emit('select', item)
closeMenu()
}
// Flip above the button if there isn't room below.
function updatePlacement() {
if (!button.value || !menu.value) return
const rect = button.value.getBoundingClientRect()
const height = menu.value.offsetHeight
const below = window.innerHeight - rect.bottom
placeAbove.value = below < height + 12 && rect.top > below
// Swap alignment when the preferred side would push the menu off-screen horizontally.
const width = menu.value.offsetWidth
const fitsEnd = rect.right - width >= 8
const fitsStart = rect.left + width <= window.innerWidth - 8
resolvedAlign.value = props.align === 'end' ? (fitsEnd || !fitsStart ? 'end' : 'start') : (fitsStart || !fitsEnd ? 'start' : 'end')
}
function move(delta: 1 | -1) {
const list = enabled.value
if (!list.length) return
const current = list.findIndex((i) => i.id === activeId.value)
const next = current === -1 ? (delta > 0 ? 0 : list.length - 1) : (current + delta + list.length) % list.length
focusItem(list[next]?.id)
}
let typeahead = ''
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined
function onTypeahead(char: string) {
typeahead += char.toLowerCase()
clearTimeout(typeaheadTimer)
typeaheadTimer = setTimeout(() => (typeahead = ''), 500)
const list = enabled.value
const start = list.findIndex((i) => i.id === activeId.value)
const ordered = [...list.slice(start + 1), ...list.slice(0, start + 1)]
const match = ordered.find((i) => i.label.toLowerCase().startsWith(typeahead))
?? (typeahead.length === 1 ? undefined : ordered.find((i) => i.label.toLowerCase().startsWith(char.toLowerCase())))
if (match) focusItem(match.id)
}
function onButtonKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openMenu('first')
} else if (event.key === 'ArrowUp') {
event.preventDefault()
openMenu('last')
}
}
function onMenuKeydown(event: KeyboardEvent) {
const list = enabled.value
switch (event.key) {
case 'ArrowDown': event.preventDefault(); move(1); break
case 'ArrowUp': event.preventDefault(); move(-1); break
case 'Home': event.preventDefault(); focusItem(list[0]?.id); break
case 'End': event.preventDefault(); focusItem(list[list.length - 1]?.id); break
case 'Escape': event.preventDefault(); event.stopPropagation(); closeMenu(); break
case 'Tab': closeMenu(false); break
case 'Enter':
case ' ': {
event.preventDefault()
const item = list.find((i) => i.id === activeId.value)
if (item) choose(item)
break
}
default:
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) onTypeahead(event.key)
}
}
// Moving the pointer off an item clears the highlight, like native menus.
function onItemLeave(id: string) {
if (activeId.value !== id) return
activeId.value = null
menu.value?.focus()
}
function onDocumentPointerDown(event: PointerEvent) {
if (open.value && root.value && !root.value.contains(event.target as Node)) closeMenu(false)
}
function onWindowChange() {
if (open.value) updatePlacement()
}
watch(() => props.disabled, (d) => d && closeMenu(false))
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
clearTimeout(typeaheadTimer)
})
</script>
<template>
<div ref="root" class="relative inline-block text-left">
<button
:id="buttonId"
ref="button"
type="button"
aria-haspopup="menu"
:aria-expanded="open"
:aria-controls="open ? menuId : undefined"
:aria-label="variant === 'icon' ? label : undefined"
:disabled="disabled"
class="inline-flex items-center justify-center gap-1.5 text-sm font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-sky-400"
:class="variant === 'icon'
? ['size-9 rounded-lg text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white', open ? 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-white' : '']
: 'rounded-lg bg-white px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-gray-300 hover:bg-gray-50 dark:bg-white/5 dark:text-white dark:ring-white/10 dark:hover:bg-white/10'"
@click="open ? closeMenu() : openMenu('none')"
@keydown="onButtonKeydown"
>
<slot name="button" :open="open">
<template v-if="variant === 'icon'">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
<path d="M3 10a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm5.5 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM15.5 8.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z" />
</svg>
</template>
<template v-else>
{{ label }}
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="-mr-1 size-4 text-gray-400 transition-transform" :class="open ? 'rotate-180' : ''">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</template>
</slot>
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
leave-active-class="transition duration-100 ease-in"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="open"
:id="menuId"
ref="menu"
role="menu"
tabindex="-1"
:aria-labelledby="buttonId"
class="absolute z-40 max-w-[calc(100vw-2rem)] rounded-xl bg-white p-1.5 shadow-lg ring-1 ring-gray-900/10 outline-none dark:bg-gray-900 dark:shadow-black/40 dark:ring-white/10"
:class="[
width,
resolvedAlign === 'end' ? 'right-0' : 'left-0',
placeAbove ? 'bottom-full mb-2' : 'top-full mt-2',
placeAbove ? (resolvedAlign === 'end' ? 'origin-bottom-right' : 'origin-bottom-left') : (resolvedAlign === 'end' ? 'origin-top-right' : 'origin-top-left'),
]"
@keydown="onMenuKeydown"
>
<div v-if="$slots.header" class="-mx-1.5 -mt-1.5 mb-1.5 border-b border-gray-100 px-3.5 py-3 dark:border-white/10">
<slot name="header" />
</div>
<template v-for="(entry, i) in items" :key="isItem(entry) ? entry.id : `${entry.type}-${i}`">
<div v-if="entry.type === 'separator'" role="separator" class="-mx-1.5 my-1.5 h-px bg-gray-100 dark:bg-white/10" />
<div v-else-if="entry.type === 'heading'" role="presentation" class="px-2.5 pt-2 pb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ entry.label }}
</div>
<button
v-else
type="button"
role="menuitem"
tabindex="-1"
:data-id="entry.id"
:aria-disabled="entry.disabled || undefined"
class="group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm outline-none"
:class="[
entry.disabled
? 'cursor-not-allowed text-gray-400 dark:text-gray-600'
: entry.danger
? 'text-red-600 focus:bg-red-50 dark:text-red-400 dark:focus:bg-red-500/10'
: 'text-gray-700 focus:bg-gray-100 focus:text-gray-900 dark:text-gray-300 dark:focus:bg-white/10 dark:focus:text-white',
]"
@click="choose(entry)"
@pointermove="!entry.disabled && activeId !== entry.id && focusItem(entry.id)"
@pointerleave="onItemLeave(entry.id)"
>
<svg v-if="entry.icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4 shrink-0" :class="entry.disabled ? '' : entry.danger ? 'text-red-500 dark:text-red-400' : 'text-gray-400 group-focus:text-sky-600 dark:text-gray-500 dark:group-focus:text-sky-400'">
<path fill-rule="evenodd" clip-rule="evenodd" :d="entry.icon" />
</svg>
<span class="flex-1 truncate">{{ entry.label }}</span>
<kbd v-if="entry.shortcut" class="font-sans text-xs text-gray-400 dark:text-gray-500">{{ entry.shortcut }}</kbd>
</button>
</template>
</div>
</Transition>
</div>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| items | MenuEntry[] | defaultMenuItems (Edit, Duplicate, Share, Download, Archive, Delete) | Menu contents. Items have id, label and optional icon (20×20 SVG path), shortcut, disabled and danger. Add { type: 'separator' } or { type: 'heading', label } between them. |
| label | string | 'Options' | Button text, or the accessible label when variant is 'icon'. |
| variant | 'button' | 'icon' | 'button' | 'button' shows the label with a chevron; 'icon' is a compact ⋯ button for rows and cards. |
| align | 'start' | 'end' | 'start' | Aligns the menu to the left or right edge of the button. |
| width | string | 'w-56' | Tailwind width class for the menu panel. |
| disabled | boolean | false | Disables the button and closes the menu. |
| Event | Payload | Description |
|---|---|---|
| select | (item: MenuItem) | Fired when an enabled item is chosen by click, Enter or Space. |
| open | () | Fired when the menu opens. |
| close | () | Fired when the menu closes. |
| Slot | Description |
|---|---|
| button | Custom button content. Receives { open }. |
| header | Content shown above the items, e.g. the signed-in user. |
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
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.
Component · Navigation
Vue 3 app sidebar with sections, icons, badges, collapsible groups, an icon-only collapsed mode and a mobile off-canvas drawer. Tailwind CSS.