<script setup lang="ts">
import { Bookmark, Home, Library, LogOut, Moon, PenLine, Search, Settings } from '@lucide/vue'
import { ref } from 'vue'
import { Button, CommandPalette, Kbd, Stack, Text, type CommandItems } from '@hina-ui/vue'
const picked = ref('')
const items: CommandItems = [
{
label: 'Pages',
items: [
{ id: 'home', label: 'Home', icon: Home },
{ id: 'library', label: 'Library', icon: Library },
{ id: 'bookmarks', label: 'Bookmarks', icon: Bookmark },
{ id: 'settings', label: 'Settings', icon: Settings, kbd: ['⌘', ','] },
],
},
{
label: 'Actions',
items: [
{
id: 'review',
label: 'New review',
icon: PenLine,
description: 'Write about a book you just finished',
},
{ id: 'theme', label: 'Toggle theme', icon: Moon, kbd: ['⌘', 'D'] },
{ id: 'logout', label: 'Sign out', icon: LogOut },
],
},
]
</script>
<template>
<Stack gap="md" align="start">
<CommandPalette :items="items" hotkey="mod+j" @select="item => (picked = item.label)">
<Button variant="outline" tone="neutral">
<template #icon><Search /></template>
Search
<Kbd>⌘J</Kbd>
</Button>
</CommandPalette>
<Text v-if="picked" size="sm" tone="muted">Selected: {{ picked }}</Text>
</Stack>
</template>
Usage
import { CommandPalette } from '@hina-ui/vue'
items is the list of entries. Each item has at least an id and a label, and may carry description, keywords, icon, kbd and onSelect; an object with label and items is a group. The default slot is the trigger. Selecting an item calls its onSelect, then emits select, then closes the panel.
<script setup lang="ts">
import { ref } from 'vue'
import { Button, CommandPalette, Stack, Text, type CommandItems } from '@hina-ui/vue'
const picked = ref('')
const items: CommandItems = [
{ id: 'home', label: 'Home' },
{ id: 'library', label: 'Library' },
{ id: 'bookmarks', label: 'Bookmarks' },
{ id: 'settings', label: 'Settings' },
]
</script>
<template>
<Stack gap="md" align="start">
<CommandPalette :items="items" @select="item => (picked = item.label)">
<Button variant="outline" tone="neutral">Open panel</Button>
</CommandPalette>
<Text v-if="picked" size="sm" tone="muted">Selected: {{ picked }}</Text>
</Stack>
</template>
Examples
Groups and descriptions
Groups have their own headings, and an item’s description sits under its label. Without a query groups keep their given order; with one, the group holding the best match comes first, and a group with no matching items is not shown.
<script setup lang="ts">
import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'
const items: CommandItems = [
{
label: 'Recently read',
items: [
{ id: 'book-1', label: 'Inherit the Stars', description: 'James P. Hogan' },
{ id: 'book-2', label: 'Hyperion', description: 'Dan Simmons' },
],
},
{
label: 'Lists',
items: [
{ id: 'list-1', label: 'To read this year', description: '12 books' },
{ id: 'list-2', label: 'Hard SF starter', description: '8 books' },
],
},
]
</script>
<template>
<CommandPalette :items="items" placeholder="Search books and lists">
<Button variant="outline" tone="neutral">Search</Button>
</CommandPalette>
</template>
Icons and key hints
icon shows before the label; kbd is a key hint shown at the end of the row. It is only a hint, the panel does not bind those keys for you.
<script setup lang="ts">
import { Copy, Moon, PenLine, Trash2 } from '@lucide/vue'
import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'
const items: CommandItems = [
{ id: 'review', label: 'New review', icon: PenLine, kbd: ['⌘', 'N'] },
{ id: 'copy', label: 'Copy link', icon: Copy, kbd: ['⌘', 'C'] },
{ id: 'theme', label: 'Toggle theme', icon: Moon, kbd: ['⌘', 'D'] },
{ id: 'delete', label: 'Delete review', icon: Trash2, disabled: true },
]
</script>
<template>
<CommandPalette :items="items">
<Button variant="outline" tone="neutral">Actions</Button>
</CommandPalette>
</template>
Global hotkey
hotkey takes a combination such as mod+k, where mod is ⌘ on Mac and Ctrl elsewhere; shift and alt can be added. Pressing it again while the panel is open closes it.
Or press ⌘⇧P
<script setup lang="ts">
import { Button, CommandPalette, Inline, Kbd, Text, type CommandItems } from '@hina-ui/vue'
const items: CommandItems = [
{ id: 'home', label: 'Home' },
{ id: 'library', label: 'Library' },
{ id: 'settings', label: 'Settings' },
]
</script>
<template>
<Inline gap="md" align="center">
<CommandPalette :items="items" hotkey="mod+shift+p">
<Button variant="outline" tone="neutral">Search</Button>
</CommandPalette>
<Text size="sm" tone="muted">
Or press
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>P</Kbd>
</Text>
</Inline>
</template>
Controlled
open supports two-way binding. Without a default slot no trigger is rendered and the panel can only be opened from outside.
Closed
<script setup lang="ts">
import { ref } from 'vue'
import { Button, CommandPalette, Inline, Text, type CommandItems } from '@hina-ui/vue'
const open = ref(false)
const items: CommandItems = [
{ id: 'home', label: 'Home' },
{ id: 'library', label: 'Library' },
{ id: 'settings', label: 'Settings' },
]
</script>
<template>
<Inline gap="md" align="center">
<Button variant="outline" tone="neutral" @click="open = true">Open from outside</Button>
<Text size="sm" tone="muted">{{ open ? 'Open' : 'Closed' }}</Text>
<CommandPalette v-model:open="open" :items="items" />
</Inline>
</template>
Custom filtering
search supports two-way binding. With ignoreFilter the panel stops filtering on its own and the item list is entirely up to the caller, for example a server-side search.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, CommandPalette, type CommandItems } from '@hina-ui/vue'
const search = ref('')
const books = [
{ id: 'b1', label: 'Inherit the Stars', tags: ['sf', 'hard sf'] },
{ id: 'b2', label: 'Hyperion', tags: ['sf', 'space opera'] },
{ id: 'b3', label: 'Foundation', tags: ['sf', 'classic'] },
{ id: 'b4', label: 'The Three-Body Problem', tags: ['sf', 'chinese'] },
]
const items = computed<CommandItems>(() => {
const query = search.value.trim().toLowerCase()
return books
.filter(book => !query || book.tags.some(tag => tag.includes(query)))
.map(book => ({ id: book.id, label: book.label, description: book.tags.join(', ') }))
})
</script>
<template>
<CommandPalette v-model:search="search" :items="items" ignore-filter placeholder="Search by tag">
<Button variant="outline" tone="neutral">Search by tag</Button>
</CommandPalette>
</template>
Inline
With inline the panel is no longer wrapped in an overlay and renders in the document flow, which suits embedding it in a page rather than summoning it with a shortcut. The inline form registers no global hotkey, and selecting an item leaves the panel in place.
<script setup lang="ts">
import { Bookmark, Home, Library, Moon, PenLine, Settings } from '@lucide/vue'
import { CommandPalette, type CommandItems } from '@hina-ui/vue'
const items: CommandItems = [
{
label: 'Pages',
items: [
{ id: 'home', label: 'Home', icon: Home },
{ id: 'library', label: 'Library', icon: Library },
{ id: 'bookmarks', label: 'Bookmarks', icon: Bookmark },
{ id: 'settings', label: 'Settings', icon: Settings, kbd: ['⌘', ','] },
],
},
{
label: 'Actions',
items: [
{
id: 'review',
label: 'New review',
icon: PenLine,
description: 'Log a book just finished',
},
{ id: 'theme', label: 'Toggle theme', icon: Moon, kbd: ['⌘', 'D'] },
],
},
]
</script>
<template>
<CommandPalette inline :items="items" class="max-w-sm" />
</template>
Search suggestions
closeOnSelect defaults to true, and selecting an item closes the panel; with false, selecting an item leaves the panel open. An item’s own closeOnSelect takes precedence over the prop, and an inline panel never closes. While the panel stays open the search text is kept and focus returns to the input.
With an empty search the example lists recent and popular searches. Those items set closeOnSelect: false and, in onSelect, write their own text into the search text bound with v-model:search; the select event can do the same. Once there is search text, the listed results close the panel when selected.
The #heading slot replaces the content of a group’s heading row and receives { group }. The heading row sits outside the option list: a button in it is reachable with Tab, and clicking it leaves focus in the input. The group’s accessible name stays group.label, and the slot also works with virtualize. The example puts a clear button at the end of the recent searches heading.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, CommandPalette, type CommandItem, type CommandItems } from '@hina-ui/vue'
const search = ref('')
const recent = ref(['Spice and Wolf', 'Keiichi Sigsawa'])
const hot = ['Hyouka', 'Book Girl']
const books: CommandItem[] = [
{ id: 'spice', label: 'Spice and Wolf', description: 'Isuna Hasekura' },
{ id: 'kino', label: "Kino's Journey", description: 'Keiichi Sigsawa' },
{ id: 'hyouka', label: 'Hyouka', description: 'Honobu Yonezawa' },
{ id: 'book-girl', label: 'Book Girl', description: 'Mizuki Nomura' },
]
function suggest(text: string): CommandItem {
return { id: text, label: text, closeOnSelect: false, onSelect: () => (search.value = text) }
}
const items = computed<CommandItems>(() =>
search.value.trim()
? books
: [
...(recent.value.length
? [{ label: 'Recent searches', items: recent.value.map(suggest) }]
: []),
{ label: 'Popular searches', items: hot.map(suggest) },
],
)
</script>
<template>
<CommandPalette v-model:search="search" :items="items" placeholder="Search by title or author">
<Button variant="outline" tone="neutral">Search books</Button>
<template #heading="{ group }">
{{ group.label }}
<Button
v-if="group.label === 'Recent searches'"
size="xs"
variant="ghost"
tone="neutral"
@click="recent = []"
>
Clear
</Button>
</template>
</CommandPalette>
</template>
Server-side search
loading marks a pending search. Without items the list area shows the loading content; with items a status row appears below the list and the items stay selectable.
The #loading slot replaces the loading content. The #empty slot replaces the content shown when there are no items and the panel is not loading; it receives { search }, where search is the current search text. Both default to text from the locale. A no-results message and a service-unavailable message both go in #empty; the caller’s state decides which one is shown.
The example sets ignoreFilter and uses a timer in place of a server; a Switch simulates the search service being unavailable.
<script setup lang="ts">
import { ref, watch } from 'vue'
import { CommandPalette, Stack, Switch, type CommandItem } from '@hina-ui/vue'
const books: CommandItem[] = [
{ id: 'spice', label: 'Spice and Wolf', description: 'Isuna Hasekura' },
{ id: 'kino', label: "Kino's Journey", description: 'Keiichi Sigsawa' },
{ id: 'hyouka', label: 'Hyouka', description: 'Honobu Yonezawa' },
{ id: 'book-girl', label: 'Book Girl', description: 'Mizuki Nomura' },
]
const keyword = ref('')
const offline = ref(false)
const items = ref<CommandItem[]>([])
const loading = ref(false)
const failed = ref(false)
watch([keyword, offline], ([text, down], _, onCleanup) => {
const query = text.trim().toLowerCase()
failed.value = false
if (!query) {
items.value = []
loading.value = false
return
}
loading.value = true
const timer = setTimeout(() => {
items.value = down ? [] : books.filter(book => book.label.toLowerCase().includes(query))
failed.value = down
loading.value = false
}, 600)
onCleanup(() => clearTimeout(timer))
})
</script>
<template>
<Stack align="start" class="w-full max-w-sm">
<Switch v-model="offline">Simulate a service outage</Switch>
<CommandPalette
v-model:search="keyword"
inline
ignore-filter
:items="items"
:loading="loading"
placeholder="Try wolf or girl"
>
<template #loading>Searching</template>
<template #empty="{ search }">
{{
failed
? 'The search service is unavailable'
: search.trim()
? `No results for "${search.trim()}"`
: 'Type a title to search'
}}
</template>
</CommandPalette>
</Stack>
</template>
Custom item content
The #item slot replaces the content inside every item row; the default icon, label, description and key hint are not rendered. The row itself stays a listbox option: highlighting, keyboard navigation, disabled and selection are unchanged, also with virtualize.
The slot receives { item, match }. match is the range { start, end } of item.label matched by the search text; it is null when the search is empty, when the item matched through keywords or description only, or with ignoreFilter.
An item’s data carries application data. Its type is inferred from items, and item.data keeps that type in the #item slot and in the select event; declare the list as CommandItems<Book>. The example puts the author in keywords: searching by author gives a null match and the title is not marked.
<script setup lang="ts">
import { CommandPalette, Mark, Text, type CommandItems } from '@hina-ui/vue'
interface Book {
id: string
title: string
author: string
volumes: number
}
const books: Book[] = [
{ id: 'spice', title: 'Spice and Wolf', author: 'Isuna Hasekura', volumes: 17 },
{ id: 'kino', title: "Kino's Journey", author: 'Keiichi Sigsawa', volumes: 23 },
{ id: 'hyouka', title: 'Hyouka', author: 'Honobu Yonezawa', volumes: 6 },
{ id: 'book-girl', title: 'Book Girl', author: 'Mizuki Nomura', volumes: 8 },
]
const items: CommandItems<Book> = books.map(book => ({
id: book.id,
label: book.title,
keywords: [book.author],
data: book,
}))
</script>
<template>
<CommandPalette inline :items="items" placeholder="Search by title or author" class="max-w-sm">
<template #item="{ item, match }">
<Text as="span" size="sm" truncate class="min-w-0 flex-1">
<template v-if="match">
<Text as="span" size="inherit">{{ item.label.slice(0, match.start) }}</Text>
<Mark>{{ item.label.slice(match.start, match.end) }}</Mark>
<Text as="span" size="inherit">{{ item.label.slice(match.end) }}</Text>
</template>
<template v-else>{{ item.label }}</template>
</Text>
<Text as="span" size="xs" tone="muted" class="shrink-0">
{{ item.data?.author }} · {{ item.data?.volumes }} volumes
</Text>
</template>
</CommandPalette>
</template>
Custom input row
The #input slot replaces the whole input row, the search icon and the input included; the default row’s height, padding and divider are removed with it. Put exactly one CommandPaletteInput inside it; that component keeps filtering, keyboard navigation, auto focus and aria-label, and uses the panel’s placeholder.
<script setup lang="ts">
import { CommandPalette, CommandPaletteInput, Inline, Tag, type CommandItems } from '@hina-ui/vue'
const items: CommandItems = [
{ id: 'spice', label: 'Spice and Wolf', description: 'Isuna Hasekura' },
{ id: 'kino', label: "Kino's Journey", description: 'Keiichi Sigsawa' },
{ id: 'hyouka', label: 'Hyouka', description: 'Honobu Yonezawa' },
{ id: 'book-girl', label: 'Book Girl', description: 'Mizuki Nomura' },
]
</script>
<template>
<CommandPalette inline :items="items" class="max-w-sm">
<template #input>
<Inline gap="sm" :wrap="false" class="border-line h-12 shrink-0 border-b px-4">
<Tag>Library</Tag>
<CommandPaletteInput placeholder="Search the library" />
</Inline>
</template>
</CommandPalette>
</template>
Virtual scrolling
virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Search still processes the full dataset. Commands unmount outside the rendered range; keep persistent state externally by command id.
Selected: —
<script setup lang="ts">
import { ref } from 'vue'
import { CommandPalette, Stack, Text } from '@hina-ui/vue'
const selected = ref('—')
const items = Array.from({ length: 10000 }, (_, index) => ({
id: String(index),
label: `Item ${String(index + 1).padStart(5, '0')}`,
keywords: [`id-${index}`],
}))
</script>
<template>
<Stack gap="sm" class="w-96 max-w-full">
<CommandPalette
:items="items"
:virtualize="{ estimateSize: 36, overscan: 6 }"
inline
aria-label="Ten thousand items"
@select="selected = $event.label"
/>
<Text size="sm" tone="muted">Selected: {{ selected }}</Text>
</Stack>
</template>
Behavior
- On open the input takes focus and the first item is highlighted; typing filters the list at once, and the matching part of a label is marked in the accent colour.
- Matches are ordered by exact label, label prefix, label substring, keywords, then description; with a query, the group holding the best match comes first. Items beyond the visible height scroll inside the list.
- Arrow keys move the highlight, Enter selects the highlighted item, and hovering moves the highlight too.
- Esc or clicking the scrim closes the panel. Selecting an item closes it too by default; when
closeOnSelectisfalsethe panel stays open, the search text is kept and focus returns to the input. The search text is cleared on close. - While
loading, the list area shows the loading content when there are no items; with items a status row appears below the list and the items stay selectable. - While open the page stops scrolling and focus stays inside the panel; it returns to the trigger on close.
- On wide screens the panel sits in the upper part of the viewport; on narrow screens it sticks to the top at full width.
Accessibility
- The panel is a dialog whose accessible name is
label, by default the locale’s “Command palette”. - The input points at the highlighted item through
aria-activedescendant; the list uses thelistboxandoptionroles, and each group carries its own name. - Icons are hidden from assistive technology; key hints are rendered as
kbdelements. - The containers of the loading content and the empty content carry
role="status"; the list carriesaria-busywhileloading.
API
Props
T is the type of an item’s data. It is inferred from items and defaults to unknown.
| Prop | Type | Default | Description |
|---|---|---|---|
items | CommandItems<T> | — | Required. Items and groups |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
placeholder | string | from locale | Placeholder of the input |
label | string | from locale | Accessible name of the panel |
hotkey | string | — | Global hotkey, e.g. mod+k |
ignoreFilter | boolean | false | Skip built-in filtering; the caller owns the list |
loading | boolean | false | Show the loading content; the list carries aria-busy |
closeOnSelect | boolean | true | Close the panel after a selection; an item’s closeOnSelect wins |
inline | boolean | false | Render as an inline panel instead of an overlay |
class | string | — | Extra classes on the panel |
Models
| Name | Type | Description |
|---|---|---|
open | boolean | Whether the panel is open |
search | string | Search text in the input |
Events
| Event | Payload | Description |
|---|---|---|
select | (item: CommandItem<T>) | Emitted when an item is chosen |
Slots
| Slot | Payload | Description |
|---|---|---|
| default | — | The trigger |
item | { item: CommandItem<T>, match: CommandItemMatch | null } | Content inside an item row; replaces the default icon, label, description and hint |
heading | { group: CommandGroup<T> } | Content of a group’s heading row, replacing the default group name |
input | — | The input row; replaces the search icon and the input |
loading | — | The loading content; defaults to text from the locale |
empty | { search: string } | Content shown with no items and not loading; defaults to text from the locale |
CommandPaletteInput
The input used when the input row is replaced. It only works inside a CommandPalette and throws when used outside one.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | the panel’s placeholder | Placeholder; takes precedence over the panel’s |
class | string | — | Extra classes on the input |
Other attributes pass through to the native input.
Types
| Field | Type | Description |
|---|---|---|
id | string | Required. Unique key of the item |
label | string | Required. Label |
description | string | Text under the label, also matched |
keywords | string[] | Matched but never shown |
icon | Component | Icon before the label |
kbd | string[] | Key hint at the end of the row |
disabled | boolean | Cannot be selected |
closeOnSelect | boolean | Whether selecting it closes the panel; wins over the component’s closeOnSelect |
data | T | Data carried by the item, not matched |
onSelect | () => void | Called when selected |
A group is { label: string; items: CommandItem<T>[] }, and CommandItems<T> is an array of items and groups.
The CommandItem<T>, CommandGroup<T>, CommandItems<T>, CommandItemMatch, CommandItemSlotProps<T>, CommandHeadingSlotProps<T> and CommandEmptySlotProps types are exported from the package entry.
interface CommandItemMatch {
start: number
end: number
}
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }