🔥 Oxide UI v0.1.0 — Dark mode only, copy-paste ready. Get started →
Skip to content

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" and aria-modal="true" on the panel
  • The input should have role="combobox", aria-expanded, aria-haspopup="listbox", and aria-controls pointing to the list
  • Each result item gets role="option" and aria-selected
  • Esc must close without navigation — handled by @keydown.escape
  • After close, focus must return to the element that opened the palette

Released under the MIT License.