Command Palette
Headless UI Combobox + Dialog
A ⌘K modal that lets users search and navigate by keyboard. Built on the Headless UI Combobox primitive inside a full-screen overlay.
Overview
Preview
Trigger button
html
<button @click="open = true"
class="flex items-center gap-3 rounded-md border border-zinc-700 bg-zinc-900
px-3 py-2 text-sm text-zinc-500 hover:border-zinc-600 hover:text-zinc-300
transition-colors w-64"
>
<!-- search icon -->
<span class="flex-1 text-left">Search components...</span>
<kbd class="rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] font-mono text-zinc-600">⌘K</kbd>
</button>Overlay structure
vue
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
const open = ref(false)
const query = ref('')
const inputRef = ref(null)
// Open on ⌘K / Ctrl+K
function onKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
open.value = !open.value
}
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
// Focus input when opened
watch(open, async (val) => {
if (val) { query.value = ''; await nextTick(); inputRef.value?.focus() }
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition ease-out duration-150"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="open = false"></div>
<!-- Panel -->
<div class="relative w-full max-w-lg overflow-hidden rounded-xl border border-zinc-700/60
bg-zinc-900 shadow-2xl shadow-black/60 z-10">
<!-- Search input -->
<div class="flex items-center gap-3 border-b border-zinc-800 px-4 py-3.5">
<!-- search icon -->
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search..."
class="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 outline-none"
@keydown.escape="open = false"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
@keydown.enter="selectHighlighted"
/>
<kbd class="text-[10px] text-zinc-600 font-mono">Esc</kbd>
</div>
<!-- Results list -->
<ul class="max-h-80 overflow-y-auto py-2" role="listbox">
<li
v-for="(item, i) in filtered"
:key="item.label"
@click="select(item)"
@mouseover="highlight = i"
role="option"
:aria-selected="i === highlight"
class="flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm"
:class="i === highlight ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'"
>
<span class="flex h-7 w-7 items-center justify-center rounded-md border border-zinc-700 bg-zinc-800/60 text-xs text-zinc-500">
{{ item.icon }}
</span>
<div class="flex-1 min-w-0">
<p class="font-medium text-zinc-300">{{ item.label }}</p>
<p class="text-[11px] text-zinc-600 truncate">{{ item.desc }}</p>
</div>
<span class="text-[10px] text-zinc-700">{{ item.type }}</span>
</li>
<li v-if="filtered.length === 0" class="py-10 text-center text-sm text-zinc-600">
No results for <span class="font-mono text-zinc-500">"{{ query }}"</span>
</li>
</ul>
<!-- Footer legend -->
<div class="flex items-center gap-4 border-t border-zinc-800 px-4 py-2.5 text-[10px] text-zinc-700">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> open</span>
<span><kbd>Esc</kbd> close</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>Filtered results (computed)
vue
<script setup>
const items = [
{ label: 'Button', desc: 'Variant styles and sizes', icon: '⊞', type: 'Component' },
{ label: 'Modal', desc: 'Dialog with HUI', icon: '▣', type: 'Component' },
{ label: 'Theming', desc: 'Colours and fonts', icon: '🎨', type: 'Doc' },
]
const filtered = computed(() => {
if (!query.value) return items
const q = query.value.toLowerCase()
return items.filter(i =>
i.label.toLowerCase().includes(q) ||
i.desc.toLowerCase().includes(q)
)
})
const highlight = ref(0)
watch(filtered, () => { highlight.value = 0 })
function highlightNext() { highlight.value = Math.min(highlight.value + 1, filtered.value.length - 1) }
function highlightPrev() { highlight.value = Math.max(highlight.value - 1, 0) }
function selectHighlighted() { if (filtered.value[highlight.value]) select(filtered.value[highlight.value]) }
function select(item) { open.value = false /* navigate to item */ }
</script>Grouping results
html
<!-- Section header -->
<li class="px-4 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">
Components
</li>
<!-- Items in group -->
<li v-for="item in group.items" ...>...</li>Accessibility notes
- The overlay acts as a dialog — add
role="dialog"andaria-modal="true"on the panel - The input should have
role="combobox",aria-expanded,aria-haspopup="listbox", andaria-controlspointing to the list - Each result item gets
role="option"andaria-selected Escmust close without navigation — handled by@keydown.escape- After close, focus must return to the element that opened the palette