Recipes

Command palette

A Cmd+K command palette built as a fullscreen modal with fuzzy search, using a dedicated modal group and useModal for clean open/close state.

Command palette

A keyboard-triggered command palette (Cmd+K / Ctrl+K) built as a modal. It uses:

  • A dedicated 'palette' group with its own <ModalTarget>.
  • useModal so that re-opening resets search state cleanly.
  • defineOptions({ modalGroup: 'palette' }) to bind the component to the group.
  • disableCloseOnInteractOverlay: true — keep the palette open if the user accidentally clicks outside the input area (the backdrop is the whole screen).

Group registration

main.ts
import { createModal } from '@kolirt/vue-modal'
import type { DefineGroups } from '@kolirt/vue-modal'

declare module '@kolirt/vue-modal' {
  interface ModalGroupRegistry extends DefineGroups<['palette']> {}
}

app.use(
  createModal({
    groups: {
      palette: {
        // Clicking outside the inner card doesn't close the palette —
        // the "outside" is the semi-transparent overlay the user
        // intentionally clicks on to dismiss.
        // Leave at defaults: closeOnInteractOverlay = true (default),
        // which means the overlay *does* close it.
        // Set disableCloseOnInteractOverlay: true if you want
        // only Esc to close the palette.
      }
    }
  })
)
App.vue
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>

<template>
  <RouterView />
  <!-- Palette sits above everything else via CSS z-index on the target -->
  <ModalTarget group="palette" class="z-[100]">
    <ModalOverlay class="bg-black/50 backdrop-blur-sm" />
  </ModalTarget>
</template>

The palette component

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
  ModalRoot,
  ModalContent,
  useModalContext
} from '@kolirt/vue-modal'

defineOptions({ modalGroup: 'palette' })

export interface Command {
  id: string
  label: string
  description?: string
  icon?: string
  action: () => void | Promise<void>
}

const props = defineProps<{ commands: Command[] }>()

const { close } = useModalContext()

const query = ref('')
const inputRef = ref<HTMLInputElement>()

// Simple fuzzy filter: every word in query must appear in label or description
const filtered = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return props.commands
  const words = q.split(/\s+/)
  return props.commands.filter((cmd) => {
    const haystack = `${cmd.label} ${cmd.description ?? ''}`.toLowerCase()
    return words.every((w) => haystack.includes(w))
  })
})

const activeIndex = ref(0)

function clamp(n: number) {
  activeIndex.value = Math.max(0, Math.min(n, filtered.value.length - 1))
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowDown') { e.preventDefault(); clamp(activeIndex.value + 1) }
  if (e.key === 'ArrowUp') { e.preventDefault(); clamp(activeIndex.value - 1) }
  if (e.key === 'Enter') run(filtered.value[activeIndex.value])
}

async function run(cmd?: Command) {
  if (!cmd) return
  close()
  await cmd.action()
}

onMounted(() => inputRef.value?.focus())
</script>

<template>
  <ModalRoot class="flex items-start justify-center pt-[15vh]">
    <ModalContent
      class="relative w-full max-w-xl rounded-2xl bg-white shadow-2xl overflow-hidden"
      @keydown="onKeydown"
    >
        <!-- Search input -->
        <div class="flex items-center gap-3 px-4 py-3 border-b border-gray-100">
          <svg class="h-5 w-5 shrink-0 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
          </svg>
          <input
            ref="inputRef"
            v-model="query"
            type="text"
            class="flex-1 text-sm outline-none placeholder:text-gray-400"
            placeholder="Search commands…"
            @input="activeIndex = 0"
          />
          <kbd class="hidden sm:inline-flex text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
            Esc
          </kbd>
        </div>

        <!-- Results -->
        <ul class="max-h-72 overflow-y-auto py-2" role="listbox">
          <li
            v-for="(cmd, i) in filtered"
            :key="cmd.id"
            role="option"
            :aria-selected="i === activeIndex"
            class="flex items-center gap-3 px-4 py-2.5 cursor-pointer text-sm transition"
            :class="i === activeIndex ? 'bg-indigo-50 text-indigo-900' : 'text-gray-800 hover:bg-gray-50'"
            @click="run(cmd)"
            @mouseenter="activeIndex = i"
          >
            <span v-if="cmd.icon" class="text-base">{{ cmd.icon }}</span>
            <div>
              <div class="font-medium">{{ cmd.label }}</div>
              <div v-if="cmd.description" class="text-xs text-gray-500">{{ cmd.description }}</div>
            </div>
          </li>

          <li v-if="filtered.length === 0" class="px-4 py-6 text-center text-sm text-gray-400">
            No results for "{{ query }}"
          </li>
        </ul>
    </ModalContent>
  </ModalRoot>
</template>

Why useModal instead of openModal

useModal gives a reactive isOpen flag that prevents re-opening while the palette is already visible. Each open() call starts fresh — the query ref inside the component resets because the component remounts.

if (!palette.isOpen.value) palette.open()

Behavior options explained

OptionDefaultEffect
disableLockBodyScrollfalseBody scroll is locked by default. Palette is fullscreen, locking is fine.
disableCloseOnEscapefalseEsc closes — good for a palette.
disableCloseOnInteractOverlayfalseClicking the backdrop closes — good default.
enableInteractOutsidefalsePage behind palette is non-interactive — correct.

The defaults work well for a palette. No overrides needed.

The fuzzy filter here is intentionally minimal. For production, consider fuse.js or minisearch for ranking and typo tolerance.
Copyright © 2026