CommandPalette

A keyboard-summoned search panel for picking commands and pages.

<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>
vue

Usage

import { CommandPalette } from '@hina-ui/vue'
ts

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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.

Pages
Home
Library
Bookmarks
Settings⌘,
Actions
New reviewLog a book just finished
Toggle theme⌘D
<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>
vue

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>
vue

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.

Type a title to search
<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>
vue

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.

Spice and WolfIsuna Hasekura · 17 volumes
Kino's JourneyKeiichi Sigsawa · 23 volumes
HyoukaHonobu Yonezawa · 6 volumes
Book GirlMizuki Nomura · 8 volumes
<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>
vue

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.

Library
Spice and WolfIsuna Hasekura
Kino's JourneyKeiichi Sigsawa
HyoukaHonobu Yonezawa
Book GirlMizuki Nomura
<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>
vue

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>
vue

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 closeOnSelect is false the 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 the listbox and option roles, and each group carries its own name.
  • Icons are hidden from assistive technology; key hints are rendered as kbd elements.
  • The containers of the loading content and the empty content carry role="status"; the list carries aria-busy while loading.

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
}
ts
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts