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>. useModalso 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>
<script setup lang="ts">
import { useModal } from '@kolirt/vue-modal'
import CommandPalette, { type Command } from './CommandPalette.vue'
import { useRouter } from 'vue-router'
import { onMounted, onBeforeUnmount } from 'vue'
const router = useRouter()
const commands: Command[] = [
{
id: 'home',
label: 'Go to Home',
icon: '🏠',
action: () => router.push('/')
},
{
id: 'settings',
label: 'Open Settings',
description: 'Manage your account preferences',
icon: '⚙️',
action: () => router.push('/settings')
},
{
id: 'new-project',
label: 'New Project',
icon: '➕',
action: () => router.push('/projects/new')
}
]
const palette = useModal(CommandPalette, { props: { commands } })
function onGlobalKeydown(e: KeyboardEvent) {
// Cmd+K on Mac, Ctrl+K elsewhere
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
if (!palette.isOpen.value) palette.open()
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</script>
<!-- This component renders nothing; mount it once anywhere
in your app to register the global Cmd+K shortcut. -->
<template>
<span />
</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
| Option | Default | Effect |
|---|---|---|
disableLockBodyScroll | false | Body scroll is locked by default. Palette is fullscreen, locking is fine. |
disableCloseOnEscape | false | Esc closes — good for a palette. |
disableCloseOnInteractOverlay | false | Clicking the backdrop closes — good default. |
enableInteractOutside | false | Page 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.Related
Image lightbox
A full-screen lightbox that navigates a list of images with arrow keys, closes on backdrop or Esc, and transitions instantly between images using replaceModal.
Nested flows / wizards
Build a multi-step wizard using replaceModal + instantEnter so steps swap without full open/close transitions. Compares step-replace vs. stacking.
