Command Palette
A ⌘K palette with fuzzy search, a gliding selection, nested pages that slide in and shortcuts.
Press ⌘J / Ctrl+J anywhere on this page.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/command-palette.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/command-palette/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { Component, HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import { highlightSegments, matchCommand } from './match'
export interface CommandPaletteItem {
/** Unique id (within its list). */
id: string
label: string
/** Group heading ("Pages", "Actions", "Recent"…). Groups keep the order they first appear in. */
group?: string
/** Secondary text shown after the label. */
hint?: string
icon?: Component
/** Keys shown on the right, e.g. `['⌘', 'N']`. */
shortcut?: string[]
/** Extra search terms that also match (without highlighting). */
keywords?: string[]
disabled?: boolean
/** Opening this item slides in a nested list instead of selecting it. */
children?: CommandPaletteItem[]
/** Search placeholder while this item's nested list is open. */
placeholder?: string
}
const props = withDefaults(defineProps<{
items: CommandPaletteItem[]
placeholder?: string
/** Key that toggles the palette together with ⌘ / Ctrl. `false` disables the global shortcut. */
hotkey?: string | false
/** Close after an item is selected. */
closeOnSelect?: boolean
/** Accessible name of the dialog. */
label?: string
/** Classes for the palette surface (it is teleported to `<body>`). */
class?: HTMLAttributes['class']
/** Classes for the backdrop. */
overlayClass?: HTMLAttributes['class']
}>(), {
placeholder: 'Type a command or search…',
hotkey: 'k',
closeOnSelect: true,
label: 'Command palette',
})
const emit = defineEmits<{
/** A leaf item was chosen. `path` lists the nested pages it was found in. */
select: [item: CommandPaletteItem, path: CommandPaletteItem[]]
}>()
const open = defineModel<boolean>('open', { default: false })
const query = ref('')
const stack = shallowRef<CommandPaletteItem[]>([])
const direction = ref(1)
const active = ref(0)
const instant = ref(true)
const uid = `vx-cmd-${useId()}`
const reduced = useReducedMotion()
const input = ref<HTMLInputElement>()
let restoreFocus: HTMLElement | null = null
const panelSpring = { type: 'spring', stiffness: 520, damping: 38 } as const
const glide = { type: 'spring', stiffness: 700, damping: 46 } as const
const heightSpring = { type: 'spring', stiffness: 480, damping: 42 } as const
const depth = computed(() => stack.value.length)
const page = computed(() => stack.value.at(-1))
const listboxId = computed(() => `${uid}-list-${depth.value}`)
const optionId = (i: number) => `${uid}-${depth.value}-o-${i}`
interface Row { item: CommandPaletteItem, index: number, indices: number[] }
/** Filtered results grouped by heading, with a flat index for keyboard nav. */
const groups = computed(() => {
const source = page.value?.children ?? props.items
const q = query.value.trim()
const scored = source.flatMap((item) => {
const hit = matchCommand(q, item.label)
if (hit)
return [{ item, ...hit }]
if (q && item.keywords?.some(k => k.toLowerCase().includes(q.toLowerCase())))
return [{ item, score: 1, indices: [] as number[] }]
return []
})
const byGroup = new Map<string, typeof scored>()
for (const s of scored) {
const key = s.item.group ?? ''
if (!byGroup.has(key))
byGroup.set(key, [])
byGroup.get(key)!.push(s)
}
let index = 0
return [...byGroup].map(([name, list]) => {
if (q)
list.sort((a, b) => b.score - a.score)
const rows: Row[] = list.map(s => ({ item: s.item, indices: s.indices, index: index++ }))
return { name, rows }
})
})
const rows = computed(() => groups.value.flatMap(g => g.rows))
const count = computed(() => rows.value.length)
function move(delta: number) {
const n = count.value
if (!n)
return
let next = active.value
for (let step = 0; step < n; step++) {
next = (next + delta + n) % n
if (!rows.value[next]!.item.disabled)
break
}
instant.value = false
active.value = next
}
function hover(row: Row) {
if (row.item.disabled || active.value === row.index)
return
instant.value = false
active.value = row.index
}
/** Keep focus in the input when clicking anywhere else on the panel. */
function onPanelMousedown(e: MouseEvent) {
if (e.target !== input.value)
e.preventDefault()
}
function firstEnabled() {
const i = rows.value.findIndex(r => !r.item.disabled)
return i === -1 ? 0 : i
}
function activate(item: CommandPaletteItem) {
if (item.disabled)
return
if (item.children) {
direction.value = 1
stack.value = [...stack.value, item]
query.value = ''
return
}
emit('select', item, stack.value)
if (props.closeOnSelect)
open.value = false
}
function back() {
if (!stack.value.length)
return
direction.value = -1
stack.value = stack.value.slice(0, -1)
query.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.isComposing)
return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
move(1)
break
case 'ArrowUp':
e.preventDefault()
move(-1)
break
case 'Enter': {
const row = rows.value[active.value]
if (row) {
e.preventDefault()
activate(row.item)
}
break
}
case 'Escape':
e.preventDefault()
e.stopPropagation()
if (stack.value.length)
back()
else
open.value = false
break
case 'Backspace':
if (!query.value && stack.value.length) {
e.preventDefault()
back()
}
break
case 'Tab':
// The input owns focus while the palette is open.
e.preventDefault()
break
}
}
// ---------------------------------------------------------------------------
// Selection highlight + body height (measured, then sprung)
// ---------------------------------------------------------------------------
const pageEl = ref<HTMLElement | null>(null)
const highlight = ref({ y: 0, height: 0, visible: false })
const bodyHeight = ref<number | 'auto'>('auto')
let observer: ResizeObserver | undefined
function setPageEl(el: unknown) {
const node = ((el as { $el?: unknown } | null)?.$el ?? el) as HTMLElement | null
if (!(node instanceof HTMLElement) || node === pageEl.value)
return
pageEl.value = node
observer?.disconnect()
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => (bodyHeight.value = node.offsetHeight))
observer.observe(node)
}
}
function syncHighlight() {
const el = pageEl.value?.querySelector<HTMLElement>(`#${CSS.escape(optionId(active.value))}`)
if (!el) {
highlight.value = { ...highlight.value, visible: false }
return
}
highlight.value = { y: el.offsetTop, height: el.offsetHeight, visible: true }
el.scrollIntoView({ block: 'nearest' })
}
watch([query, stack], () => {
active.value = firstEnabled()
instant.value = true
})
watch([active, rows, pageEl], () => nextTick(syncHighlight), { flush: 'post' })
const highlightTransition = computed(() =>
instant.value || reduced.value ? { duration: 0 } : glide,
)
const pageVariants = {
enter: (dir: number) => ({ x: reduced.value ? 0 : dir * 56, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: reduced.value ? 0 : dir * -56, opacity: 0 }),
}
// ---------------------------------------------------------------------------
// Open / close, focus, scroll lock, global shortcut
// ---------------------------------------------------------------------------
watch(open, async (isOpen) => {
if (typeof document === 'undefined')
return
if (isOpen) {
restoreFocus = document.activeElement as HTMLElement | null
query.value = ''
stack.value = []
active.value = firstEnabled()
instant.value = true
bodyHeight.value = 'auto'
document.documentElement.style.overflow = 'hidden'
await nextTick()
input.value?.focus()
}
else {
document.documentElement.style.overflow = ''
observer?.disconnect()
pageEl.value = null
restoreFocus?.focus({ preventScroll: true })
restoreFocus = null
}
})
function onGlobalKeydown(e: KeyboardEvent) {
if (!props.hotkey || !(e.metaKey || e.ctrlKey) || e.altKey)
return
if (e.key.toLowerCase() !== props.hotkey.toLowerCase())
return
e.preventDefault()
open.value = !open.value
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown)
observer?.disconnect()
if (open.value)
document.documentElement.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<AnimatePresence>
<motion.div
v-if="open"
key="overlay"
aria-hidden="true"
:class="cn('fixed inset-0 z-[90] bg-black/40 backdrop-blur-[2px]', props.overlayClass)"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0, transition: { duration: 0.15 } }"
@click="open = false"
/>
</AnimatePresence>
<AnimatePresence>
<motion.div
v-if="open"
key="panel"
role="dialog"
aria-modal="true"
:aria-label="props.label"
:class="cn(
'fixed inset-x-0 top-[12vh] z-[91] mx-auto flex w-[min(640px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-2xl',
props.class,
)"
:initial="{ opacity: 0, scale: reduced ? 1 : 0.96, y: reduced ? 0 : -12 }"
:animate="{ opacity: 1, scale: 1, y: 0 }"
:exit="{ opacity: 0, scale: reduced ? 1 : 0.98, transition: { duration: 0.14 } }"
:transition="panelSpring"
@keydown="onKeydown"
@mousedown="onPanelMousedown"
>
<!-- Search row -->
<div class="flex items-center gap-2 border-b px-4">
<svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<AnimatePresence mode="popLayout" :initial="false">
<motion.button
v-if="page"
:key="page.id"
type="button"
aria-label="Back"
class="inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
:initial="{ opacity: 0, scale: 0.8, filter: 'blur(4px)' }"
:animate="{ opacity: 1, scale: 1, filter: 'blur(0px)' }"
:exit="{ opacity: 0, scale: 0.8, filter: 'blur(4px)' }"
:transition="panelSpring"
@click="back"
>
<svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
{{ page.label }}
</motion.button>
</AnimatePresence>
<input
ref="input"
v-model="query"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="true"
:aria-controls="listboxId"
:aria-activedescendant="rows[active] ? optionId(active) : undefined"
:placeholder="page?.placeholder ?? props.placeholder"
autocomplete="off"
autocorrect="off"
spellcheck="false"
class="h-14 min-w-0 flex-1 border-0 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
>
<kbd class="hidden h-5 shrink-0 items-center rounded border bg-muted px-1.5 font-sans text-[11px] text-muted-foreground sm:inline-flex">esc</kbd>
</div>
<!-- Results -->
<motion.div
class="relative overflow-hidden"
:initial="false"
:animate="{ height: bodyHeight }"
:transition="reduced ? { duration: 0 } : heightSpring"
>
<AnimatePresence :initial="false" mode="popLayout" :custom="direction">
<motion.div
:id="listboxId"
:key="`page-${depth}-${page?.id ?? 'root'}`"
:ref="setPageEl"
role="listbox"
:aria-label="page ? page.label : 'Results'"
class="max-h-[min(22rem,55vh)] w-full scroll-py-2 overflow-y-auto overscroll-contain p-2"
:variants="pageVariants"
initial="enter"
animate="center"
exit="exit"
:transition="panelSpring"
>
<div class="relative">
<motion.div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-0 rounded-lg bg-accent"
:initial="false"
:animate="{ y: highlight.y, height: highlight.height, opacity: highlight.visible ? 1 : 0 }"
:transition="highlightTransition"
>
<span class="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-brand" />
</motion.div>
<div
v-for="(group, gi) in groups"
:key="group.name || `g${gi}`"
role="group"
:aria-labelledby="group.name ? `${uid}-${depth}-g${gi}` : undefined"
>
<div
v-if="group.name"
:id="`${uid}-${depth}-g${gi}`"
class="px-3 pb-1.5 pt-2.5 text-xs font-medium text-muted-foreground"
>
{{ group.name }}
</div>
<div
v-for="row in group.rows"
:id="optionId(row.index)"
:key="row.item.id"
role="option"
:aria-selected="row.index === active"
:aria-disabled="row.item.disabled || undefined"
:class="cn(
'relative flex min-h-11 cursor-pointer select-none items-center gap-3 rounded-lg px-3 text-sm transition-colors',
row.index === active ? 'text-accent-foreground' : 'text-foreground',
row.item.disabled && 'cursor-not-allowed opacity-50',
)"
@pointermove="hover(row)"
@click="activate(row.item)"
>
<span
v-if="row.item.icon"
class="flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground [&_svg]:size-3.5"
>
<component :is="row.item.icon" aria-hidden="true" />
</span>
<span class="min-w-0 flex-1 truncate">
<template v-for="(seg, si) in highlightSegments(row.item.label, row.indices)" :key="si">
<span v-if="seg.match" class="rounded-[3px] bg-brand/20 font-semibold text-foreground">{{ seg.text }}</span>
<template v-else>{{ seg.text }}</template>
</template>
<span v-if="row.item.hint" class="ml-2 text-muted-foreground">{{ row.item.hint }}</span>
</span>
<span v-if="row.item.shortcut?.length" class="flex shrink-0 items-center gap-1" aria-hidden="true">
<kbd
v-for="key in row.item.shortcut"
:key="key"
class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[11px] text-muted-foreground"
>{{ key }}</kbd>
</span>
<svg v-else-if="row.item.children" class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</div>
</div>
<div v-if="!count" class="flex flex-col items-center gap-2 px-6 py-10 text-center">
<slot name="empty" :query="query">
<span class="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5M8.5 8.5l5 5M13.5 8.5l-5 5" /></svg>
</span>
<span class="text-sm font-medium text-foreground">No results for “{{ query }}”</span>
<span class="text-xs text-muted-foreground">Try a different keyword or check the spelling.</span>
</slot>
</div>
</div>
</motion.div>
</AnimatePresence>
</motion.div>
<div class="flex items-center justify-between gap-4 border-t bg-muted/40 px-4 py-2 text-xs text-muted-foreground">
<slot name="footer">
<span class="flex items-center gap-3">
<span class="flex items-center gap-1"><kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-background px-1 font-sans text-[11px]">↑</kbd><kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-background px-1 font-sans text-[11px]">↓</kbd> navigate</span>
<span class="flex items-center gap-1"><kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-background px-1 font-sans text-[11px]">↵</kbd> {{ rows[active]?.item.children ? 'open' : 'select' }}</span>
</span>
<span class="flex items-center gap-1">
<kbd class="inline-flex h-5 items-center justify-center rounded border bg-background px-1 font-sans text-[11px]">{{ page ? '⌫' : 'esc' }}</kbd>
{{ page ? 'back' : 'close' }}
</span>
</slot>
</div>
<span class="sr-only" aria-live="polite">{{ count }} {{ count === 1 ? 'result' : 'results' }}</span>
</motion.div>
</AnimatePresence>
</Teleport>
</template>/**
* Small "fuzzy-ish" matcher for the command palette.
*
* - A contiguous substring wins (bonus at the start of the text or of a word).
* - Otherwise every query character must appear in order (a subsequence);
* consecutive characters and word starts score higher, gaps score lower.
*
* Returns the score and the matched character indices (for highlighting),
* or `null` when the text doesn't match.
*/
export interface CommandMatch {
score: number
indices: number[]
}
const BOUNDARY = /[\s\-_/.:]/
export function matchCommand(query: string, text: string): CommandMatch | null {
const q = query.trim().toLowerCase()
if (!q)
return { score: 0, indices: [] }
const t = text.toLowerCase()
const at = t.indexOf(q)
if (at !== -1) {
const wordStart = at === 0 || BOUNDARY.test(t[at - 1]!)
return {
score: 100 + (at === 0 ? 40 : 0) + (wordStart ? 25 : 0) - at,
indices: Array.from({ length: q.length }, (_, i) => at + i),
}
}
const indices: number[] = []
let score = 0
let from = 0
for (const ch of q) {
if (ch === ' ')
continue
const found = t.indexOf(ch, from)
if (found === -1)
return null
const prev = indices.at(-1)
if (prev !== undefined && found === prev + 1)
score += 6
else if (found === 0 || BOUNDARY.test(t[found - 1]!))
score += 4
else
score -= Math.min(found - from, 6) * 0.5
indices.push(found)
from = found + 1
}
return { score, indices }
}
/** Splits `text` into plain / matched runs for rendering. */
export function highlightSegments(text: string, indices: number[]) {
const hit = new Set(indices)
const out: { text: string, match: boolean }[] = []
for (let i = 0; i < text.length; i++) {
const match = hit.has(i)
const last = out.at(-1)
if (last && last.match === match)
last.text += text[i]
else
out.push({ text: text[i]!, match })
}
return out
}export { default as CommandPalette } from './CommandPalette.vue'
export type { CommandPaletteItem } from './CommandPalette.vue'
export { highlightSegments, matchCommand } from './match'
export type { CommandMatch } from './match'Usage
import { CommandPalette, type CommandPaletteItem } from '@/components/ui/command-palette'<script setup lang="ts">
const open = ref(false)
const items: CommandPaletteItem[] = [
{ id: 'home', label: 'Home', group: 'Pages', icon: House },
{ id: 'new', label: 'New file', group: 'Actions', shortcut: ['⌘', 'N'] },
{
id: 'theme',
label: 'Change theme',
group: 'Actions',
children: [
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' },
],
},
]
</script>
<template>
<CommandPalette v-model:open="open" :items="items" @select="run" />
</template>Items are grouped by group (in the order groups first appear). Typing filters with a forgiving subsequence match — chth finds Change theme — and highlights the matched characters. An item with children opens a nested list that slides in; Backspace on an empty query or Escape slides back.
The global shortcut is registered on mount. This docs site's own search already owns ⌘K, so the demo above passes hotkey="j" (⌘J / Ctrl+J).
API
| Prop | Type | Default | Description |
|---|---|---|---|
items | CommandPaletteItem[] | — | Items to search (see below). |
v-model:open | boolean | false | Whether the palette is open. |
placeholder | string | 'Type a command or search…' | Root search placeholder. |
hotkey | string | false | 'k' | Toggles the palette with ⌘ / Ctrl + key. false disables it. |
close-on-select | boolean | true | Close after a leaf item is selected. |
label | string | 'Command palette' | Accessible name of the dialog. |
class | string | — | Classes for the palette surface (teleported to <body>). |
overlay-class | string | — | Classes for the backdrop. |
CommandPaletteItem:
| Field | Type | Description |
|---|---|---|
id | string | Unique id within its list. |
label | string | Text that is searched and highlighted. |
group | string | Group heading. |
hint | string | Muted text after the label. |
icon | Component | Icon component. |
shortcut | string[] | Keys shown on the right, e.g. ['⌘', 'N']. |
keywords | string[] | Extra search terms. |
disabled | boolean | Shown but not selectable. |
children | CommandPaletteItem[] | Opens a nested list instead of selecting. |
placeholder | string | Search placeholder inside the nested list. |
| Event | Payload | Description |
|---|---|---|
select | (item, path) | A leaf item was chosen; path lists the nested pages above it. |
| Slot | Props | Description |
|---|---|---|
empty | { query } | Replaces the empty state. |
footer | — | Replaces the keyboard hints. |
The input is a combobox controlling a listbox (aria-activedescendant follows the selection). ↑ / ↓ move, Enter selects, Escape goes back or closes, and focus returns to where it was.