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
| Prop | Default | Description |
|---|---|---|
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. |
placeholder | Search… | Placeholder of the search input. |
size | xl | Maximum 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. |
filter | true | Turn 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. | ||
| Prop | Default | Description |
|---|---|---|
CommandPalettebuilt on the Reka UI <ComboboxRoot> component | ||
open | - | Controls whether the palette is shown. |
placeholder | Search… | Placeholder of the search input. |
size | xl | Maximum 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. |
filter | true | Turn 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.
With a footer
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.
Navigating and acting
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>