Skip to content
  1. Navigation
  2. Command palette

Command palette

Search, jump and act without ever reaching for the mouse.

'use client'

import { useState } from 'react'
import { Button } from '@/components/button'
import {
  CommandPalette,
  CommandPaletteItem,
  CommandPaletteLabel,
} from '@/components/command-palette'

export default function Example() {
  let [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <Button type="button" onClick={() => setIsOpen(true)}>
        Search
      </Button>
      <CommandPalette open={isOpen} onClose={setIsOpen}>
        <CommandPaletteItem value="home" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Home</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="inbox" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Inbox</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="tickets" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Tickets</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="settings" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Settings</CommandPaletteLabel>
        </CommandPaletteItem>
      </CommandPalette>
    </>
  )
}
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import CommandPalette from '@/components/command-palette.vue'
import CommandPaletteItem from '@/components/command-palette-item.vue'
import CommandPaletteLabel from '@/components/command-palette-label.vue'

const isOpen = ref(false)
</script>

<template>
  <Button type="button" @click="isOpen = true">Search</Button>
  <CommandPalette :open="isOpen" @close="isOpen = false">
    <CommandPaletteItem value="home" @select="isOpen = false">
      <CommandPaletteLabel>Home</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="inbox" @select="isOpen = false">
      <CommandPaletteLabel>Inbox</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="tickets" @select="isOpen = false">
      <CommandPaletteLabel>Tickets</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="settings" @select="isOpen = false">
      <CommandPaletteLabel>Settings</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPalette>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { createContext, useContext, useEffect, useId, useRef, useState } from 'react'
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'

const sizes = {
  sm: 'sm:max-w-sm',
  md: 'sm:max-w-md',
  lg: 'sm:max-w-lg',
  xl: 'sm:max-w-xl',
  '2xl': 'sm:max-w-2xl',
  '3xl': 'sm:max-w-3xl',
}

type PaletteContextValue = {
  filterTerm: string
  handlers: Map<string, (() => void) | undefined>
}

const PaletteContext = createContext<PaletteContextValue | null>(null)

function normalize(value: string) {
  return value
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/gu, '')
}

function textOf(node: React.ReactNode): string {
  if (node === null || node === undefined || typeof node === 'boolean') return ''
  if (typeof node === 'string' || typeof node === 'number') return String(node)
  if (Array.isArray(node)) return node.map(textOf).join(' ')
  if (React.isValidElement<{ children?: React.ReactNode }>(node)) return textOf(node.props.children)
  return ''
}

function useResultAnnouncement(list: React.RefObject<HTMLElement | null>, dependency: unknown): string {
  let [announcement, setAnnouncement] = useState('')

  useEffect(() => {
    let count = list.current?.querySelectorAll('[role="option"]').length ?? 0
    let timer = setTimeout(() => {
      setAnnouncement(count === 0 ? 'No results' : `${count} result${count > 1 ? 's' : ''}`)
    }, 250)

    return () => clearTimeout(timer)
  }, [list, dependency])

  return announcement
}

export function CommandPalette({
  size = 'xl',
  placeholder = 'Search…',
  label = 'Search',
  filterTerm,
  className,
  children,
  breadcrumb,
  panel,
  footer,
  query: controlledQuery,
  onQueryChange,
  onChange,
  onTab,
  onBack,
  onEscape,
  ...props
}: {
  size?: keyof typeof sizes
  placeholder?: string
  label?: string
  filterTerm?: string
  className?: string
  children: React.ReactNode
  breadcrumb?: React.ReactNode
  panel?: React.ReactNode
  footer?: React.ReactNode
  query?: string
  onQueryChange?: (query: string) => void
  onChange?: (value: string) => void
  onTab?: (value: string | null) => void
  onBack?: () => boolean
  onEscape?: () => boolean
} & Omit<Headless.DialogProps, 'as' | 'className' | 'children'>) {
  let [uncontrolledQuery, setUncontrolledQuery] = useState('')
  let query = controlledQuery ?? uncontrolledQuery
  let [handlers] = useState(() => new Map<string, (() => void) | undefined>())
  let list = useRef<HTMLDivElement>(null)
  let footerId = useId()
  let announcement = useResultAnnouncement(list, `${filterTerm ?? query}`)

  function updateQuery(next: string) {
    if (controlledQuery === undefined) setUncontrolledQuery(next)
    onQueryChange?.(next)
  }

  function onKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>) {
    if (event.key === 'Tab' && !event.shiftKey && onTab) {
      event.preventDefault()
      event.stopPropagation()

      let activeId = event.currentTarget.querySelector('input')?.getAttribute('aria-activedescendant')
      onTab(activeId ? (document.getElementById(activeId)?.dataset.commandValue ?? null) : null)

      return
    }

    if (event.key === 'Escape') {
      event.preventDefault()
      event.stopPropagation()
      if (onEscape?.() !== true) props.onClose(false)
      return
    }

    if (event.key === 'Backspace' && (event.target as HTMLInputElement).value === '' && onBack?.() === true) {
      event.preventDefault()
      event.stopPropagation()
    }
  }

  return (
    <Headless.Dialog aria-label={label} {...props} className="relative z-50">
      <Headless.DialogBackdrop
        transition
        className="fixed inset-0 bg-zinc-950/25 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in motion-reduce:duration-0 dark:bg-zinc-950/50"
      />

      <div className="fixed inset-0 w-screen overflow-y-auto p-4 sm:p-6 md:p-20">
        <Headless.DialogPanel
          transition
          onKeyDownCapture={onKeyDownCapture}
          className={clsx(
            className,
            sizes[size],
            'mx-auto w-full divide-y divide-zinc-950/5 overflow-hidden rounded-xl',
            'bg-white dark:divide-white/10 dark:bg-zinc-900',
            'shadow-lg ring-1 dark:ring-inset ring-zinc-950/10 dark:ring-white/10',
            'forced-colors:outline',
            'transition duration-100 will-change-transform data-closed:translate-y-4 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in motion-reduce:duration-0 sm:data-closed:translate-y-0 sm:data-closed:scale-95'
          )}
        >
          <PaletteContext.Provider value={{ filterTerm: filterTerm ?? query, handlers }}>
            <Headless.Combobox
              immediate
              onChange={(value: string | null) => {
                if (value === null) return
                handlers.get(value)?.()
                onChange?.(value)
              }}
              onClose={() => updateQuery('')}
            >
              <div className="flex items-center gap-3 px-4">
                <MagnifyingGlassIcon className="size-5 shrink-0 fill-zinc-500 sm:size-4 dark:fill-zinc-400 forced-colors:fill-[CanvasText]" />

                {breadcrumb}

                <Headless.ComboboxInput
                  autoFocus
                  aria-label={label}
                  aria-describedby={footer ? footerId : undefined}
                  autoComplete="off"
                  placeholder={placeholder}
                  value={query}
                  onChange={(event) => updateQuery(event.target.value)}
                  className={clsx(
                    'min-w-0 flex-1 appearance-none bg-transparent py-4 sm:py-3.5',
                    'text-base/6 sm:text-sm/6',
                    'text-zinc-950 dark:text-white placeholder:text-zinc-500',
                    'focus:outline-hidden'
                  )}
                />
              </div>

              <Headless.ComboboxOptions
                ref={list}
                static
                className="group/list max-h-[60dvh] scroll-py-2 overflow-y-auto overscroll-contain p-2 empty:hidden sm:max-h-96"
              >
                {children}
              </Headless.ComboboxOptions>

              {panel}

              <span role="status" aria-live="polite" className="sr-only">
                {announcement}
              </span>

              {footer && (
                <div id={footerId} className="px-4 py-2.5">
                  {footer}
                </div>
              )}
            </Headless.Combobox>
          </PaletteContext.Provider>
        </Headless.DialogPanel>
      </div>
    </Headless.Dialog>
  )
}

export function CommandPaletteGroup({
  heading,
  className,
  children,
  ...props
}: { heading?: string } & React.ComponentPropsWithoutRef<'div'>) {
  let headingId = useId()

  return (
    <div
      {...props}
      role={heading ? 'group' : undefined}
      aria-labelledby={heading ? headingId : undefined}
      className={clsx(className, 'not-first:mt-2 not-has-[[role=option]]:hidden')}
    >
      {heading && (
        <div id={headingId} className="px-2 py-1.5 text-xs/5 font-medium text-zinc-500 dark:text-zinc-400">
          {heading}
        </div>
      )}
      {children}
    </div>
  )
}

export function CommandPaletteItem({
  value,
  keywords,
  filter = true,
  onSelect,
  className,
  children,
  ...props
}: {
  value: string
  keywords?: string
  filter?: boolean
  onSelect?: () => void
  className?: string
  children?: React.ReactNode
} & Omit<Headless.ComboboxOptionProps<'div', string>, 'as' | 'className' | 'value' | 'children'>) {
  let context = useContext(PaletteContext)

  useEffect(() => {
    context?.handlers.set(value, onSelect)
    return () => {
      context?.handlers.delete(value)
    }
  })

  let term = normalize(context?.filterTerm.trim() ?? '')
  let haystack = normalize(`${value} ${keywords ?? ''} ${textOf(children)}`)
  if (filter && term !== '' && !haystack.includes(term)) return null

  return (
    <Headless.ComboboxOption
      {...props}
      value={value}
      data-command-value={value}
      className={clsx(
        className,
        'group flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2.5 sm:py-2',
        'text-base/6 sm:text-sm/6 text-zinc-950 dark:text-white',
        'outline-hidden focus:outline-hidden focus-visible:outline-hidden data-focus:bg-accent-500 data-focus:text-white',
        'data-disabled:cursor-default data-disabled:opacity-50',
        '*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4',
        '*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
        '*:data-[slot=avatar]:-my-0.5 *:data-[slot=avatar]:size-6 *:data-[slot=avatar]:shrink-0 sm:*:data-[slot=avatar]:size-5',
        'forced-color-adjust-none forced-colors:text-[CanvasText]',
        'forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:bg-[Highlight]',
        'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]'
      )}
    >
      {children}
    </Headless.ComboboxOption>
  )
}

export function CommandPaletteLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
  return <span data-slot="label" {...props} className={clsx(className, 'min-w-0 flex-1 truncate')} />
}

export function CommandPaletteDescription({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
  return (
    <span
      data-slot="description"
      {...props}
      className={clsx(
        className,
        'shrink-0 truncate text-zinc-500 group-data-focus:text-white dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
      )}
    />
  )
}

export function CommandPaletteKbd({ className, ...props }: React.ComponentPropsWithoutRef<'kbd'>) {
  return (
    <kbd
      {...props}
      className={clsx(
        className,
        'inline-flex min-w-5 items-center justify-center rounded border border-zinc-950/10 px-1 py-px font-sans text-xs/5 text-zinc-500 dark:border-white/15 dark:text-zinc-400'
      )}
    />
  )
}

export function CommandPaletteHint({
  keys = '↵',
  className,
  children,
  ...props
}: { keys?: string; className?: string; children: React.ReactNode } & Omit<
  React.ComponentPropsWithoutRef<'span'>,
  'children'
>) {
  return (
    <span
      {...props}
      aria-hidden="true"
      className={clsx(
        className,
        'hidden shrink-0 items-center gap-1.5 text-xs/5 text-zinc-500 group-data-focus:flex group-data-focus:text-white max-sm:group-data-focus:hidden dark:text-zinc-400'
      )}
    >
      <CommandPaletteKbd className="border-current/25 text-inherit">{keys}</CommandPaletteKbd>
      {children}
    </span>
  )
}

export function CommandPaletteShortcut({
  keys,
  className,
  ...props
}: { keys: string | string[]; className?: string } & React.ComponentPropsWithoutRef<'kbd'>) {
  return (
    <kbd {...props} className={clsx(className, 'flex shrink-0')}>
      {(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
        <kbd
          key={index}
          className={clsx([
            'min-w-[2ch] text-center font-sans capitalize text-zinc-400',
            'group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
            index > 0 && char.length > 1 && 'pl-1',
          ])}
        >
          {char}
        </kbd>
      ))}
    </kbd>
  )
}

export function CommandPaletteEmpty({ className, children, ...props }: React.ComponentPropsWithoutRef<'div'>) {
  return (
    <div
      {...props}
      className={clsx(
        className,
        'hidden px-6 py-14 text-center text-base/7 text-zinc-500 group-not-has-[[role=option]]/list:block sm:text-sm/6 dark:text-zinc-400'
      )}
    >
      {children}
    </div>
  )
}
<script lang="ts">
import type { ComputedRef, InjectionKey } from 'vue'

export const CommandPaletteTermKey: InjectionKey<ComputedRef<string>> = Symbol('TrelundCommandPaletteTerm')
</script>

<script setup lang="ts">
import { ComboboxContent, ComboboxInput, ComboboxRoot, FocusScope } from 'reka-ui'
import { computed, onBeforeUnmount, provide, ref, useId, watch } from 'vue'
import { useScrollLock } from './use-scroll-lock'
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid'

const sizes = {
  sm: 'sm:max-w-sm',
  md: 'sm:max-w-md',
  lg: 'sm:max-w-lg',
  xl: 'sm:max-w-xl',
  '2xl': 'sm:max-w-2xl',
  '3xl': 'sm:max-w-3xl',
}

const props = withDefaults(
  defineProps<{
    open: boolean
    placeholder?: string
    size?: keyof typeof sizes
    label?: string
    filterTerm?: string
    onTab?: (value: string | null) => void
  }>(),
  { placeholder: 'Search…', size: 'xl', label: 'Search', filterTerm: undefined, onTab: undefined }
)

const query = defineModel<string>('query', { default: '' })

const emit = defineEmits<{
  close: []
  back: [event: KeyboardEvent]
  escape: [event: KeyboardEvent]
}>()

provide(
  CommandPaletteTermKey,
  computed(() => props.filterTerm ?? query.value)
)

const locked = useScrollLock()
const footerId = useId()
const panel = ref<HTMLElement>()

const root = ref<{ highlightFirstItem?: () => void }>()

function highlightFirst() {
  setTimeout(() => {
    if (!panel.value?.querySelector('[data-highlighted]')) root.value?.highlightFirstItem?.()
  })
}

const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(highlightFirst)
watch(panel, (element) => {
  observer?.disconnect()
  if (element) observer?.observe(element, { childList: true, subtree: true })
})

function activeValue() {
  return (panel.value?.querySelector('[data-highlighted]') as HTMLElement | null)?.dataset.commandValue ?? null
}

function onEscape(event: KeyboardEvent) {
  emit('escape', event)
  if (!event.defaultPrevented) emit('close')
}

function onKeydownCapture(event: KeyboardEvent) {
  if (event.key === 'Tab' && !event.shiftKey && props.onTab) {
    event.preventDefault()
    event.stopPropagation()
    props.onTab(activeValue())
    return
  }
  if (event.key === 'Escape') {
    onEscape(event)
    event.preventDefault()
    event.stopPropagation()
    return
  }
  if (event.key === 'Backspace' && (event.target as HTMLInputElement).value === '') {
    emit('back', event)
    if (event.defaultPrevented) event.stopPropagation()
  }
}

function onDocumentKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape' && !panel.value?.contains(event.target as Node)) onEscape(event)
}

function onOutsideMousedown(event: MouseEvent) {
  if (event.target === event.currentTarget) emit('close')
}

const announcement = ref('')

watch(
  [() => props.filterTerm ?? query.value, () => props.open],
  ([, open], _, onCleanup) => {
    if (!open) return
    const timer = setTimeout(() => {
      const count = panel.value?.querySelectorAll('[role="listbox"] [role="option"]').length ?? 0
      announcement.value = count === 0 ? 'No results' : `${count} result${count > 1 ? 's' : ''}`
    }, 250)
    onCleanup(() => clearTimeout(timer))
  },
  { immediate: true }
)

watch(
  () => props.open,
  (open, wasOpen) => {
    locked.value = open
    if (typeof document === 'undefined') return
    if (open) document.addEventListener('keydown', onDocumentKeydown)
    else {
      document.removeEventListener('keydown', onDocumentKeydown)
      if (wasOpen) query.value = ''
    }
  },
  { immediate: true }
)

onBeforeUnmount(() => {
  observer?.disconnect()
  document.removeEventListener('keydown', onDocumentKeydown)
  locked.value = false
})
</script>

<template>
  <Teleport to="body">
    <Transition
      enter-active-class="transition duration-100 ease-out"
      enter-from-class="opacity-0"
      leave-active-class="transition duration-100 ease-in"
      leave-to-class="opacity-0"
    >
      <div v-if="open" aria-hidden="true" class="fixed inset-0 z-50 bg-zinc-950/25 dark:bg-zinc-950/50" />
    </Transition>

    <div
      v-if="open"
      class="fixed inset-0 z-50 w-screen overflow-y-auto p-4 sm:p-6 md:p-20"
      @mousedown="onOutsideMousedown"
    >
      <Transition
        appear
        enter-active-class="transition duration-100 ease-out will-change-transform"
        enter-from-class="translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95"
      >
        <FocusScope as-child trapped loop>
          <ComboboxRoot ref="root" :open="true" ignore-filter as-child>
            <div
              ref="panel"
              role="dialog"
              aria-modal="true"
              :aria-label="label"
              :class="[
                sizes[size],
                'mx-auto w-full divide-y divide-zinc-950/5 overflow-hidden rounded-xl',
                'bg-white dark:divide-white/10 dark:bg-zinc-900',
                'shadow-lg ring-1 dark:ring-inset ring-zinc-950/10 dark:ring-white/10',
                'forced-colors:outline',
              ]"
              @keydown.capture="onKeydownCapture"
            >
              <div class="flex items-center gap-3 px-4">
                <MagnifyingGlassIcon class="size-5 shrink-0 fill-zinc-500 sm:size-4 dark:fill-zinc-400 forced-colors:fill-[CanvasText]" />
                <slot name="breadcrumb" />
                <ComboboxInput
                  v-model="query"
                  auto-focus
                  :aria-label="label"
                  :aria-describedby="$slots.footer ? footerId : undefined"
                  autocomplete="off"
                  :placeholder="placeholder"
                  :class="[
                    'min-w-0 flex-1 appearance-none bg-transparent py-4 sm:py-3.5',
                    'text-base/6 sm:text-sm/6',
                    'text-zinc-950 dark:text-white placeholder:text-zinc-500',
                    'focus:outline-hidden',
                  ]"
                />
                <slot name="input" />
              </div>

              <ComboboxContent
                class="group/list max-h-[60dvh] scroll-py-2 overflow-y-auto overscroll-contain p-2 empty:hidden sm:max-h-96"
              >
                <slot />
              </ComboboxContent>

              <slot name="panel" />

              <span role="status" aria-live="polite" class="sr-only">{{ announcement }}</span>

              <div v-if="$slots.footer" :id="footerId" class="px-4 py-2.5">
                <slot name="footer" />
              </div>
            </div>
          </ComboboxRoot>
        </FocusScope>
      </Transition>
    </div>
  </Teleport>
</template>

Component API

PropDefaultDescription
CommandPalettebuilt on the Headless UI <Headless.Dialog> component
open-Controls whether the palette is shown.
onClose-Called when the user presses Escape or clicks the backdrop.
placeholderSearch…Placeholder of the search input.
sizexlMaximum width of the panel, from sm to 3xl.
footer-Content kept under the list, for keyboard hints.
onChange-Called with the value of the selected item.
label'Search'Accessible name of the dialog and the input, since the palette has no visible label.
breadcrumb-Rendered before the input: the open scope.
panel-Rendered in place of the list: a help screen, a rich empty state.
query-The typed text, controlled from outside to clear it when the scope changes.
onQueryChange-Tracks the query, to feed the list from a remote source.
filterTerm-Replaces the typed text for filtering, when a prefix such as `>` precedes it.
onTab-Tab, with the value of the highlighted row. The key press is swallowed only when this is set.
onBack-Backspace on an empty input. Return `true` to consume it.
onEscape-Escape, before the palette closes. Return `true` to keep it open.
CommandPaletteGrouprenders a <div> element
heading-Label rendered above the items of the group.
CommandPaletteItembuilt on the Headless UI <Headless.ComboboxOption> component
value-Unique value identifying the item.
keywords-Extra terms the filter accepts, on top of the rendered text.
filtertrueTurn off for a list already filtered at the source, a server or a scope.
onSelect-Called when the item is clicked or selected with Enter.
disabled-Prevents the item from being selected.
CommandPaletteLabelrenders a <span> element
This component does not expose any component-specific props.
CommandPaletteDescriptionrenders a <span> element
This component does not expose any component-specific props.
CommandPaletteShortcutrenders a <kbd> element
keys-Keyboard shortcut, as a string of characters or an array of key names.
CommandPaletteHintrenders a <span> element
keys↵Key shown before the text.
CommandPaletteKbdrenders a <kbd> element
This component does not expose any component-specific props.
CommandPaletteEmptyrenders a <div> element
This component does not expose any component-specific props.
PropDefaultDescription
CommandPalettebuilt on the Reka UI <ComboboxRoot> component
open-Controls whether the palette is shown.
placeholderSearch…Placeholder of the search input.
sizexlMaximum width of the panel, from sm to 3xl.
@close-Emitted when the user presses Escape or clicks the backdrop.
label'Search'Accessible name of the dialog and the input, since the palette has no visible label.
v-model:query-The typed text. Bind it to clear it when the scope changes, or to feed the list from a remote source.
filterTerm-Replaces the typed text for filtering, when a prefix such as `>` precedes it.
#breadcrumb-Rendered before the input: the open scope.
#panel-Rendered in place of the list: a help screen, a rich empty state.
#footer-Keyboard hints, kept under the list.
@tab-Tab, with the value of the highlighted row. The key press is swallowed only when a listener is set.
@back-Backspace on an empty input. Prevent the event to consume it.
@escape-Escape, before the palette closes. Prevent the event to keep it open.
CommandPaletteGroupbuilt on the Reka UI <ComboboxGroup> component
heading-Label rendered above the items of the group.
CommandPaletteItembuilt on the Reka UI <ComboboxItem> component
value-Unique value identifying the item.
keywords-Extra terms the filter accepts, on top of the rendered text.
filtertrueTurn off for a list already filtered at the source, a server or a scope.
disabled-Prevents the item from being selected.
@select-Emitted when the item is clicked or selected with Enter.
CommandPaletteLabelrenders a <span> element
This component does not expose any component-specific props.
CommandPaletteDescriptionrenders a <span> element
This component does not expose any component-specific props.
CommandPaletteShortcutrenders a <kbd> element
keys-Keyboard shortcut, as a string of characters or an array of key names.
CommandPaletteHintrenders a <span> element
keys↵Key shown before the text.
CommandPaletteKbdrenders a <kbd> element
This component does not expose any component-specific props.
CommandPaletteEmptyrenders a <div> element
This component does not expose any component-specific props.

Examples

Basic example

Combine the CommandPalette, CommandPaletteItem and CommandPaletteLabel components to build a palette:

'use client'

import { useState } from 'react'
import { Button } from '@/components/button'
import {
  CommandPalette,
  CommandPaletteItem,
  CommandPaletteLabel,
} from '@/components/command-palette'

export default function Example() {
  let [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <Button type="button" onClick={() => setIsOpen(true)}>
        Search
      </Button>
      <CommandPalette open={isOpen} onClose={setIsOpen}>
        <CommandPaletteItem value="home" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Home</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="inbox" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Inbox</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="tickets" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Tickets</CommandPaletteLabel>
        </CommandPaletteItem>
        <CommandPaletteItem value="settings" onSelect={() => setIsOpen(false)}>
          <CommandPaletteLabel>Settings</CommandPaletteLabel>
        </CommandPaletteItem>
      </CommandPalette>
    </>
  )
}
<script setup lang="ts">
import { ref } from 'vue'
import Button from '@/components/button.vue'
import CommandPalette from '@/components/command-palette.vue'
import CommandPaletteItem from '@/components/command-palette-item.vue'
import CommandPaletteLabel from '@/components/command-palette-label.vue'

const isOpen = ref(false)
</script>

<template>
  <Button type="button" @click="isOpen = true">Search</Button>
  <CommandPalette :open="isOpen" @close="isOpen = false">
    <CommandPaletteItem value="home" @select="isOpen = false">
      <CommandPaletteLabel>Home</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="inbox" @select="isOpen = false">
      <CommandPaletteLabel>Inbox</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="tickets" @select="isOpen = false">
      <CommandPaletteLabel>Tickets</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="settings" @select="isOpen = false">
      <CommandPaletteLabel>Settings</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPalette>
</template>

Palettes are controlled components: open and close them with the open prop and the onClose handler. Items are filtered on the text they render, so nothing has to be filtered by hand.

Grouping results

Wrap items in a CommandPaletteGroup to label them by type:

<CommandPalette open={isOpen} onClose={setIsOpen}>
  <CommandPaletteGroup heading="Tickets">
    <CommandPaletteItem value="ticket-4821" onSelect={close}>
      <CommandPaletteLabel>Refund not received</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="ticket-4817" onSelect={close}>
      <CommandPaletteLabel>Cannot reset password</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPaletteGroup>
  <CommandPaletteGroup heading="Customers">
    <CommandPaletteItem value="mariam" onSelect={close}>
      <CommandPaletteLabel>Mariam Keita</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="chidi" onSelect={close}>
      <CommandPaletteLabel>Chidi Okeke</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPaletteGroup>
</CommandPalette>
<CommandPalette :open="isOpen" @close="isOpen = false">
  <CommandPaletteGroup heading="Tickets">
    <CommandPaletteItem value="ticket-4821" @select="isOpen = false">
      <CommandPaletteLabel>Refund not received</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="ticket-4817" @select="isOpen = false">
      <CommandPaletteLabel>Cannot reset password</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPaletteGroup>
  <CommandPaletteGroup heading="Customers">
    <CommandPaletteItem value="mariam" @select="isOpen = false">
      <CommandPaletteLabel>Mariam Keita</CommandPaletteLabel>
    </CommandPaletteItem>
    <CommandPaletteItem value="chidi" @select="isOpen = false">
      <CommandPaletteLabel>Chidi Okeke</CommandPaletteLabel>
    </CommandPaletteItem>
  </CommandPaletteGroup>
</CommandPalette>

A group disappears on its own as soon as the search filters out every item it contains.

With icons

Add an icon as the first child of an item:

<CommandPaletteGroup heading="Go to">
  <CommandPaletteItem value="home" onSelect={close}>
    <HomeIcon />
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="inbox" onSelect={close}>
    <InboxIcon />
    <CommandPaletteLabel>Inbox</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="tickets" onSelect={close}>
    <TicketIcon />
    <CommandPaletteLabel>Tickets</CommandPaletteLabel>
  </CommandPaletteItem>
</CommandPaletteGroup>
<CommandPaletteGroup heading="Go to">
  <CommandPaletteItem value="home" @select="isOpen = false">
    <HomeIcon />
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="inbox" @select="isOpen = false">
    <InboxIcon />
    <CommandPaletteLabel>Inbox</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="tickets" @select="isOpen = false">
    <TicketIcon />
    <CommandPaletteLabel>Tickets</CommandPaletteLabel>
  </CommandPaletteItem>
</CommandPaletteGroup>

With descriptions

Use the CommandPaletteDescription component to say where an item comes from:

<CommandPaletteItem value="refund" onSelect={close}>
  <CreditCardIcon />
  <CommandPaletteLabel>Refund payment</CommandPaletteLabel>
  <CommandPaletteDescription>Billing</CommandPaletteDescription>
</CommandPaletteItem>
<CommandPaletteItem value="refund" @select="isOpen = false">
  <CreditCardIcon />
  <CommandPaletteLabel>Refund payment</CommandPaletteLabel>
  <CommandPaletteDescription>Billing</CommandPaletteDescription>
</CommandPaletteItem>

The description is right-aligned and searchable like the rest of the row: typing orders keeps the refund item.

With shortcuts

Use the CommandPaletteShortcut component to show the keyboard shortcut of a command:

<CommandPaletteItem value="new-ticket" onSelect={close}>
  <PlusIcon />
  <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  <CommandPaletteShortcut keys={['⌘', 'N']} />
</CommandPaletteItem>
<CommandPaletteItem value="new-ticket" @select="isOpen = false">
  <PlusIcon />
  <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  <CommandPaletteShortcut :keys="['⌘', 'N']" />
</CommandPaletteItem>

With a hint

Use the CommandPaletteHint component to say what Enter does on the highlighted item. It only shows on that item, and stays hidden on small screens where there is no Enter key:

<CommandPaletteItem value="new-ticket" onSelect={close}>
  <PlusIcon />
  <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  <CommandPaletteHint>Create</CommandPaletteHint>
</CommandPaletteItem>
<CommandPaletteItem value="new-ticket" @select="isOpen = false">
  <PlusIcon />
  <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  <CommandPaletteHint>Create</CommandPaletteHint>
</CommandPaletteItem>

With avatars

Render an Avatar instead of an icon to search through people:

<CommandPaletteGroup heading="Team">
  {people.map((person) => (
    <CommandPaletteItem key={person.id} value={person.id} keywords={person.name} onSelect={close}>
      <Avatar initials={person.initials} className="bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />
      <CommandPaletteLabel>{person.name}</CommandPaletteLabel>
      <CommandPaletteDescription>{person.role}</CommandPaletteDescription>
    </CommandPaletteItem>
  ))}
</CommandPaletteGroup>
<CommandPaletteGroup heading="Team">
  <CommandPaletteItem v-for="person in people" :key="person.id" :value="person.id" @select="isOpen = false">
    <Avatar :initials="person.initials" class="bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />
    <CommandPaletteLabel>{{ person.name }}</CommandPaletteLabel>
    <CommandPaletteDescription>{{ person.role }}</CommandPaletteDescription>
  </CommandPaletteItem>
</CommandPaletteGroup>

The name is passed as keywords so the filter matches it even though the label is rendered by another component.

Empty state

Add a CommandPaletteEmpty component to say something when nothing matches:

<CommandPalette open={isOpen} onClose={setIsOpen}>
  <CommandPaletteItem value="home" onSelect={close}>
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>

  <CommandPaletteEmpty>No results found.</CommandPaletteEmpty>
</CommandPalette>
<CommandPalette :open="isOpen" @close="isOpen = false">
  <CommandPaletteItem value="home" @select="isOpen = false">
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>

  <CommandPaletteEmpty>No results found.</CommandPaletteEmpty>
</CommandPalette>

It is only shown when the search matches no item at all.

Use the footer prop to keep the keyboard hints in sight:

<CommandPalette
  open={isOpen}
  onClose={setIsOpen}
  footer={
    <p className="flex items-center gap-4 text-xs/5 text-zinc-500 dark:text-zinc-400">
      <span className="flex items-center gap-1.5">
        <kbd className="font-sans">↑↓</kbd>Navigate
      </span>
      <span className="flex items-center gap-1.5">
        <kbd className="font-sans">↵</kbd>Select
      </span>
      <span className="flex items-center gap-1.5">
        <kbd className="font-sans">esc</kbd>Close
      </span>
    </p>
  }
>
  <CommandPaletteItem value="home" onSelect={close}>
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>
</CommandPalette>
<CommandPalette :open="isOpen" @close="isOpen = false">
  <CommandPaletteItem value="home" @select="isOpen = false">
    <CommandPaletteLabel>Home</CommandPaletteLabel>
  </CommandPaletteItem>

  <template #footer>
    <p class="flex items-center gap-4 text-xs/5 text-zinc-500 dark:text-zinc-400">
      <span class="flex items-center gap-1.5"><kbd class="font-sans">↑↓</kbd>Navigate</span>
      <span class="flex items-center gap-1.5"><kbd class="font-sans">↵</kbd>Select</span>
      <span class="flex items-center gap-1.5"><kbd class="font-sans">esc</kbd>Close</span>
    </p>
  </template>
</CommandPalette>

Scopes

A row that carries a scope opens with Tab: the list then searches inside a register, or lists what can be done with one record. useCommandScopes keeps the stack, registerScope builds the search of a register with its ten latest records. Backspace on an empty input and Escape step back out:

import { registerScope, useCommandScopes } from '@/components/command-scopes'

const customerScope = registerScope({
  label: 'Customers',
  placeholder: 'Search by name or email',
  records: () => customers,
  matches: (customer, term) => `${customer.name} ${customer.email}`.toLowerCase().includes(term.toLowerCase()),
  toItem: (customer) => ({ id: customer.id, title: customer.name, hint: customer.email, initials: customer.initials }),
})

const destinations = [
  { id: 'customers', title: 'Customers', icon: UsersIcon, scope: customerScope },
  { id: 'payments', title: 'Payments', icon: CreditCardIcon },
]

function Palette() {
  let [query, setQuery] = useState('')
  let { scopes, scope, enterScope, exitScope, resetScopes } = useCommandScopes(setQuery)
  let groups = scope ? scope.resolve(query) : [{ key: 'nav', label: 'Jump to', items: destinations }]
  let byId = new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item]))

  return (
    <CommandPalette
      open={isOpen}
      onClose={(value) => { setIsOpen(value); if (!value) resetScopes() }}
      query={query}
      onQueryChange={setQuery}
      placeholder={scope?.placeholder ?? 'Search or jump to…'}
      breadcrumb={scopes.map((entry) => <span key={entry.label}>{entry.label} /</span>)}
      onTab={(value) => { let item = value && byId.get(value); if (item) enterScope(item) }}
      onBack={exitScope}
      onEscape={exitScope}
    >
      {groups.map((group) => (
        <CommandPaletteGroup key={group.key} heading={group.label}>
          {group.items.map((item) => (
            <CommandPaletteItem key={item.id} value={item.id} filter={!scope} keywords={item.hint}>
              <CommandPaletteLabel>{item.title}</CommandPaletteLabel>
              {item.scope && <CommandPaletteHint keys="⇥">to search</CommandPaletteHint>}
            </CommandPaletteItem>
          ))}
        </CommandPaletteGroup>
      ))}
    </CommandPalette>
  )
}
<script setup lang="ts">
import { registerScope, useCommandScopes } from '@/components/command-scopes'

const customerScope = registerScope({
  label: 'Customers',
  placeholder: 'Search by name or email',
  records: () => customers,
  matches: (customer, term) => `${customer.name} ${customer.email}`.toLowerCase().includes(term.toLowerCase()),
  toItem: (customer) => ({ id: customer.id, title: customer.name, hint: customer.email, initials: customer.initials }),
})

const destinations = [
  { id: 'customers', title: 'Customers', icon: UsersIcon, scope: customerScope },
  { id: 'payments', title: 'Payments', icon: CreditCardIcon },
]

const query = ref('')
const { scopes, scope, enterScope, exitScope, resetScopes } = useCommandScopes(query)
const groups = computed(() => (scope.value ? scope.value.resolve(query.value) : [{ key: 'nav', label: 'Jump to', items: destinations }]))

function onTab(value: string | null) {
  const item = groups.value.flatMap((group) => group.items).find((entry) => entry.id === value)
  if (item) enterScope(item)
}

function onExit(event: KeyboardEvent) {
  if (exitScope()) event.preventDefault()
}
</script>

<template>
  <CommandPalette
    v-model:query="query"
    :open="isOpen"
    :placeholder="scope?.placeholder ?? 'Search or jump to…'"
    @close="isOpen = false; resetScopes()"
    @tab="onTab"
    @back="onExit"
    @escape="onExit"
  >
    <template #breadcrumb>
      <span v-for="entry in scopes" :key="entry.label">{{ entry.label }} /</span>
    </template>
    <CommandPaletteGroup v-for="group in groups" :key="group.key" :heading="group.label">
      <CommandPaletteItem v-for="item in group.items" :key="item.id" :value="item.id" :filter="!scope" :keywords="item.hint">
        <CommandPaletteLabel>{{ item.title }}</CommandPaletteLabel>
        <CommandPaletteHint v-if="item.scope" keys="⇥">to search</CommandPaletteHint>
      </CommandPaletteItem>
    </CommandPaletteGroup>
  </CommandPalette>
</template>

Inside a scope the rows come already filtered by resolve, so pass filter={false} to the items. The breadcrumb shows where the reader is, and the placeholder says what the scope searches.

Opening with a keyboard shortcut

Toggle the open prop from a window listener to open the palette with ⌘K:

Press ⌘K to open the palette.

'use client'

import { useEffect, useState } from 'react'
import { CommandPalette } from '@/components/command-palette'

export default function Example() {
  let [isOpen, setIsOpen] = useState(false)

  useEffect(() => {
    function onKeydown(event: KeyboardEvent) {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
        event.preventDefault()
        setIsOpen((open) => !open)
      }
    }

    window.addEventListener('keydown', onKeydown)
    return () => window.removeEventListener('keydown', onKeydown)
  }, [])

  return (
    <CommandPalette open={isOpen} onClose={setIsOpen}>
      {/* ... */}
    </CommandPalette>
  )
}
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import CommandPalette from '@/components/command-palette.vue'

const isOpen = ref(false)

function onKeydown(event: KeyboardEvent) {
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
    event.preventDefault()
    isOpen.value = !isOpen.value
  }
}

onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>

<template>
  <CommandPalette :open="isOpen" @close="isOpen = false">
    <!-- ... -->
  </CommandPalette>
</template>

Mount the palette once, high in the application, and open it from anywhere. Use ctrlKey as well as metaKey so the shortcut works outside of macOS.

Handle onSelect to decide what an item does:

An item that navigates should close the palette and change the route. An item that mutates something is better off opening the screen that owns the flow (with a query parameter, for instance) than rebuilding its form inside the palette. Only cheap, reversible commands, like copying a value or toggling a preference, belong inside the palette itself.

<CommandPaletteGroup heading="Actions">
  <CommandPaletteItem value="new-ticket" onSelect={createEvent}>
    <PlusIcon />
    <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="invite" onSelect={() => router.push('/settings/team?invite=1')}>
    <UserPlusIcon />
    <CommandPaletteLabel>Invite teammate</CommandPaletteLabel>
    <CommandPaletteDescription>Team</CommandPaletteDescription>
  </CommandPaletteItem>
  <CommandPaletteItem value="copy-key" onSelect={() => copy(publishableKey)}>
    <CommandPaletteLabel>Copy publishable key</CommandPaletteLabel>
  </CommandPaletteItem>
</CommandPaletteGroup>
<CommandPaletteGroup heading="Actions">
  <CommandPaletteItem value="new-ticket" @select="createEvent()">
    <PlusIcon />
    <CommandPaletteLabel>New ticket</CommandPaletteLabel>
  </CommandPaletteItem>
  <CommandPaletteItem value="invite" @select="navigateTo('/settings/team?invite=1')">
    <UserPlusIcon />
    <CommandPaletteLabel>Invite teammate</CommandPaletteLabel>
    <CommandPaletteDescription>Team</CommandPaletteDescription>
  </CommandPaletteItem>
  <CommandPaletteItem value="copy-key" @select="copy(publishableKey)">
    <CommandPaletteLabel>Copy publishable key</CommandPaletteLabel>
  </CommandPaletteItem>
</CommandPaletteGroup>