Command
Command is a searchable list of actions with keyboard navigation. Use it for command palettes and quick search. For picking a form value, use Combobox.
'use client';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/nxds/components/command/command';
const paletteClass =
'nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md';
export default function CommandDefault() {
return (
<Command label="Command menu" className={paletteClass}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
<CommandItem>Calculator</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}
Installation
npx nxds-cli@latest add command
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Usage
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
CommandSeparator,
CommandShortcut,
} from '@/components/nxds/components/command/command';
Composition
Command
├── CommandInput
├── CommandList
│ ├── CommandEmpty
│ ├── CommandGroup
│ │ └── CommandItem
│ │ └── CommandShortcut
│ └── CommandSeparator
└── CommandLoading
Examples
Grouped
Sort commands into CommandGroup sections with headings and separators.
'use client';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/nxds/components/command/command';
const paletteClass =
'nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md';
export default function CommandGrouped() {
return (
<Command label="Command menu" className={paletteClass}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>
Profile
<CommandShortcut>⌘P</CommandShortcut>
</CommandItem>
<CommandItem>
Billing
<CommandShortcut>⌘B</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}
Async loading
Show CommandLoading outside CommandList while results load.
'use client';
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
} from '@/components/nxds/components/command/command';
import { Spinner } from '@/components/nxds/components/spinner/spinner';
const paletteClass =
'nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md';
export default function CommandAsyncLoading() {
return (
<Command label="Async command menu" className={paletteClass}>
<CommandInput placeholder="Search commands..." />
{/* Loading lives outside CommandList: cmdk's Loading carries
role="progressbar", which is an invalid child of the list's
role="listbox" (axe aria-required-children). */}
<CommandLoading progress={60} label="Loading command results">
<Spinner
aria-hidden="true"
role="presentation"
className="nx:size-3.5"
/>
Loading commands...
</CommandLoading>
<CommandList>
<CommandGroup heading="Recent">
{['Recent project', 'Team dashboard'].map((item) => (
<CommandItem key={item} value={item}>
{item}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
This example also needs: Spinner.
Rich items
Give each item an icon, a description and a trailing hint.
'use client';
import {
BookIcon,
FileIcon,
GearIcon,
UsersIcon,
} from '@phosphor-icons/react/ssr';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@/components/nxds/components/command/command';
const paletteClass =
'nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md';
const projectCommands = [
{
label: 'Open project plan',
description: 'View roadmap, milestones, and project notes',
value: 'open-project-plan',
keywords: ['roadmap', 'milestones', 'docs'],
icon: FileIcon,
shortcut: '⌘P',
},
{
label: 'Search documentation',
description: 'Find API references and implementation guides',
value: 'search-documentation',
keywords: ['docs', 'api', 'guide'],
icon: BookIcon,
shortcut: '⌘D',
},
{
label: 'Project settings',
description: 'Update members, billing, and notifications',
value: 'project-settings',
keywords: ['preferences', 'configuration', 'admin'],
icon: GearIcon,
trailing: 'Admin',
},
{
label: 'Invite teammate',
description: 'Add a member to this workspace',
value: 'invite-teammate',
keywords: ['members', 'users', 'people'],
icon: UsersIcon,
},
];
function CommandRichItem({
command,
}: {
command: (typeof projectCommands)[number];
}) {
const Icon = command.icon;
return (
<CommandItem
value={command.value}
keywords={command.keywords}
className="nx:items-start"
>
<Icon aria-hidden="true" className="nx:mt-0.5 nx:text-muted-foreground" />
<span className="nx:flex nx:min-w-0 nx:flex-1 nx:flex-col nx:gap-0.5">
<span className="nx:truncate">{command.label}</span>
<span className="nx:truncate nx:typography-body-default nx:text-muted-foreground">
{command.description}
</span>
</span>
{command.shortcut ? (
<CommandShortcut className="nx:mt-0.5">
{command.shortcut}
</CommandShortcut>
) : command.trailing ? (
<span className="nx:mt-0.5 nx:ml-auto nx:shrink-0 nx:typography-label-small nx:text-muted-foreground">
{command.trailing}
</span>
) : null}
</CommandItem>
);
}
export default function CommandRichItems() {
return (
<Command label="Project command menu" className={paletteClass}>
<CommandInput placeholder="Search project commands..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Project">
{projectCommands.map((command) => (
<CommandRichItem key={command.value} command={command} />
))}
</CommandGroup>
</CommandList>
</Command>
);
}
Props
Command
Command adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandDialog
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | —No default documented | Class names for the DialogContent. |
description | string | "Search for a command to run..." | Visually hidden dialog description. |
showCloseButton | boolean | false | Shows the dialog's close button. Escape and clicking outside close it either way. |
title | string | "Command Palette" | Visually hidden dialog title. Also labels the search input. |
CommandEmpty
CommandEmpty adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandGroup
CommandGroup adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandInput
CommandInput adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandItem
CommandItem adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandList
CommandList adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandLoading
CommandLoading adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandSeparator
CommandSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandShortcut
CommandShortcut adds no props of its own; it accepts the props of the element or primitive it wraps.
Command is a searchable list of actions with keyboard navigation. Use it for command palettes and quick search. For picking a form value, use Combobox.
<script setup lang="ts">
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/command';
</script>
<template>
<Command
label="Command menu"
class="nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md"
>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
<CommandItem>Calculator</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
Installation
- 1
Install these components first:
- 2
Copy and paste the following code into your project.
components/command/Command.vue <script setup lang="ts"> import { ListboxRoot } from 'reka-ui'; import { computed, type ComputedRef, type HTMLAttributes, provide, ref, shallowReactive, toRef, useId, useTemplateRef, } from 'vue'; import { commandScore } from '../../lib/command-score'; import { cn } from '../../lib/utils'; import { type CommandItemEntry, CommandKey } from '.'; export interface CommandProps { /** Accessible name for the search input. */ label?: string; /** Hides the items the search doesn't match. Turn it off to filter items yourself. */ shouldFilter?: boolean; /** Arrow keys wrap from the last item to the first, and back. */ loop?: boolean; class?: HTMLAttributes['class']; } const props = withDefaults(defineProps<CommandProps>(), { shouldFilter: true, loop: false, }); const search = ref(''); const items = shallowReactive(new Map<string, ComputedRef<CommandItemEntry>>()); const isFiltering = computed(() => props.shouldFilter && search.value !== ''); const scores = computed(() => { const scored = new Map<string, number>(); if (!isFiltering.value) return scored; for (const [id, entry] of items) { const { value, keywords } = entry.value; scored.set(id, value ? commandScore(value, search.value, keywords) : 0); } return scored; }); const matchingIds = computed( () => new Set( [...scores.value].filter(([, score]) => score > 0).map(([id]) => id) ) ); const matchingGroups = computed( () => new Set([...matchingIds.value].map((id) => items.get(id)?.value.group)) ); function registerItem(id: string, entry: ComputedRef<CommandItemEntry>) { items.set(id, entry); return () => items.delete(id); } const inputId = useId(); const labelId = useId(); provide(CommandKey, { label: toRef(props, 'label'), search, inputId, labelId, listId: useId(), count: computed(() => isFiltering.value ? matchingIds.value.size : items.size ), registerItem, isItemShown: (id) => !isFiltering.value || matchingIds.value.has(id), isGroupShown: (id) => !isFiltering.value || matchingGroups.value.has(id), }); // Reka clears the highlight when the pointer leaves the list. Keep it, so // Enter still runs the last highlighted item. const listbox = useTemplateRef('listbox'); let lastHighlighted: HTMLElement | undefined; function keepHighlight() { if (!listbox.value || !lastHighlighted?.isConnected) return; listbox.value.highlightedElement = lastHighlighted; } </script> <template> <ListboxRoot ref="listbox" data-slot="command" cmdk-root tabindex="-1" :loop="loop" highlight-on-hover :class=" cn( 'nx:flex nx:h-full nx:w-full nx:flex-col nx:overflow-hidden nx:rounded-md', 'nx:bg-popover nx:text-popover-foreground', props.class ) " @highlight="lastHighlighted = $event?.ref" @leave="keepHighlight" > <label :id="labelId" :for="inputId" cmdk-label class="nx:sr-only"> {{ label }} </label> <slot /> </ListboxRoot> </template>components/command/CommandDialog.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { DialogRoot, type DialogRootEmits, type DialogRootProps, useForwardPropsEmits, } from 'reka-ui'; import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import { DialogContent, DialogDescription, DialogTitle } from '../dialog'; import Command from './Command.vue'; export interface CommandDialogProps extends DialogRootProps { /** * Visually hidden dialog title. Also labels the search input. */ title?: string; /** * Visually hidden dialog description. */ description?: string; /** * Shows the dialog's close button. Escape and clicking outside close it either way. */ showCloseButton?: boolean; /** * Class names for the DialogContent. */ class?: HTMLAttributes['class']; } const props = withDefaults(defineProps<CommandDialogProps>(), { title: 'Command Palette', description: 'Search for a command to run...', showCloseButton: false, }); const emits = defineEmits<DialogRootEmits>(); const delegated = reactiveOmit( props, 'title', 'description', 'showCloseButton', 'class' ); const forwarded = useForwardPropsEmits(delegated, emits); </script> <template> <DialogRoot v-bind="forwarded"> <DialogContent :show-close-button="showCloseButton" :class="cn('nx:overflow-hidden nx:p-0', props.class)" > <DialogTitle class="nx:sr-only">{{ title }}</DialogTitle> <DialogDescription class="nx:sr-only"> {{ description }} </DialogDescription> <Command :label="title"><slot /></Command> </DialogContent> </DialogRoot> </template>components/command/CommandEmpty.vue <script setup lang="ts"> import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import { injectCommand } from '.'; export interface CommandEmptyProps { class?: HTMLAttributes['class']; } const props = defineProps<CommandEmptyProps>(); const command = injectCommand(); </script> <template> <div v-if="command.count.value === 0" data-slot="command-empty" cmdk-empty role="presentation" :class=" cn('nx:py-6 nx:text-center nx:typography-body-default', props.class) " > <slot /> </div> </template>components/command/CommandGroup.vue <script setup lang="ts"> import { computed, provide, toRef, useId } from 'vue'; import { cn } from '../../lib/utils'; import { CommandGroupKey, type CommandGroupProps, injectCommand } from '.'; const props = withDefaults(defineProps<CommandGroupProps>(), { forceMount: false, }); const command = injectCommand(); const id = useId(); const headingId = useId(); provide(CommandGroupKey, { id, forceMount: toRef(props, 'forceMount') }); const shown = computed(() => props.forceMount || command.isGroupShown(id)); </script> <template> <div data-slot="command-group" cmdk-group role="presentation" :data-value="heading" :hidden="shown ? undefined : true" :class=" cn( 'nx:overflow-hidden nx:text-popover-foreground', 'nx:p-2', 'nx:**:[[cmdk-group-heading]]:px-2 nx:**:[[cmdk-group-heading]]:py-1.5', 'nx:**:[[cmdk-group-heading]]:typography-label-small nx:**:[[cmdk-group-heading]]:text-muted-foreground', props.class ) " > <div v-if="heading" :id="headingId" cmdk-group-heading aria-hidden="true"> {{ heading }} </div> <div cmdk-group-items role="group" :aria-labelledby="heading ? headingId : undefined" > <slot /> </div> </div> </template>components/command/CommandInput.vue <script setup lang="ts"> import { ListboxFilter } from 'reka-ui'; import { watch } from 'vue'; import { IconSearch } from '../../lib/icons'; import { cn } from '../../lib/utils'; import { type CommandInputProps, injectCommand } from '.'; defineOptions({ inheritAttrs: false }); const props = defineProps<CommandInputProps>(); const emit = defineEmits<{ 'update:modelValue': [value: string] }>(); const command = injectCommand(); watch( () => props.modelValue, (value) => { if (value !== undefined) command.search.value = value; }, { immediate: true } ); function updateSearch(value: string) { if (props.modelValue === undefined) command.search.value = value; emit('update:modelValue', value); } </script> <template> <div data-slot="command-input-wrapper" class="nx:flex nx:items-center nx:border-b-default nx:border-border-default nx:px-3 nx:focus-within:border-border-focus" > <IconSearch class="nx:mr-2 nx:size-4 nx:shrink-0 nx:opacity-50" /> <ListboxFilter v-bind="{ 'data-slot': 'command-input', ...$attrs }" :id="command.inputId" :model-value="command.search.value" cmdk-input autocomplete="off" autocorrect="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" :aria-controls="command.listId" :aria-labelledby="command.labelId" :class=" cn( 'nx:flex nx:w-full nx:border-0 nx:bg-transparent nx:px-0 nx:py-2 nx:text-inherit nx:typography-body-default nx:outline-none', 'nx:placeholder:text-muted-foreground', 'nx:disabled:cursor-not-allowed nx:disabled:text-disabled-foreground nx:disabled:placeholder:text-disabled-foreground', props.class ) " @update:model-value="updateSearch" /> </div> </template>components/command/CommandItem.vue <script setup lang="ts"> import { injectListboxRootContext, ListboxItem } from 'reka-ui'; import { computed, inject, onMounted, onUnmounted, onUpdated, ref, useId, useTemplateRef, watch, } from 'vue'; import { cn } from '../../lib/utils'; import { CommandGroupKey, type CommandItemProps, injectCommand } from '.'; defineOptions({ inheritAttrs: false }); const props = withDefaults(defineProps<CommandItemProps>(), { disabled: false, forceMount: undefined, }); const emit = defineEmits<{ select: [value: string] }>(); const command = injectCommand(); const group = inject(CommandGroupKey, undefined); const listbox = injectListboxRootContext(); const id = useId(); const element = useTemplateRef<HTMLElement>('element'); const text = ref(''); function readText() { const content = element.value?.textContent; if (content != null) text.value = content.trim(); } onMounted(readText); onUpdated(readText); const entry = computed(() => ({ value: props.value?.trim() ?? text.value, keywords: props.keywords?.map((keyword) => keyword.trim()) ?? [], group: group?.id, })); const forceMount = computed( () => props.forceMount ?? group?.forceMount.value ?? false ); watch( forceMount, (forced, _, onCleanup) => { if (!forced) onCleanup(command.registerItem(id, entry)); }, { immediate: true } ); const shown = computed(() => forceMount.value || command.isItemShown(id)); const highlighted = computed( () => !props.disabled && listbox.highlightedElement.value?.id === id ); // A hidden item can't stay highlighted, or the input would point // aria-activedescendant at an element that is gone. function releaseHighlight() { if (highlighted.value) listbox.highlightedElement.value = null; } watch(shown, (isShown) => { if (!isShown) releaseHighlight(); }); onUnmounted(releaseHighlight); // Set over Reka's own item attributes, so the item reads as cmdk's: selected // means highlighted, and the disabled state is a "true" or "false" string. const itemAttrs = computed(() => ({ id, 'cmdk-item': '', role: 'option', 'aria-disabled': props.disabled, 'aria-selected': highlighted.value, 'data-disabled': props.disabled, 'data-selected': highlighted.value, 'data-value': entry.value.value, 'data-highlighted': undefined, 'data-state': undefined, class: cn( 'nx:relative nx:flex nx:cursor-default nx:select-none nx:items-center nx:rounded-sm nx:typography-body-default nx:outline-none', 'nx:gap-3 nx:px-3 nx:py-2.5', 'nx:data-[selected=true]:bg-popover-hover nx:data-[selected=true]:text-popover-foreground', 'nx:data-[disabled=true]:pointer-events-none nx:data-[disabled=true]:text-disabled-foreground', 'nx:[&_svg]:pointer-events-none nx:[&_svg]:size-4 nx:[&_svg]:shrink-0', props.class ), })); </script> <template> <!-- A hidden item still renders its content, so the search can read its text. --> <div v-if="!shown" ref="element" hidden> <slot /> </div> <template v-else> <!-- A disabled item stays out of Reka's collection, so arrow keys skip it. --> <div v-if="disabled" ref="element" v-bind="{ 'data-slot': 'command-item', ...$attrs, ...itemAttrs }" > <slot /> </div> <ListboxItem v-else :value="id" as-child @select="emit('select', entry.value)" > <div ref="element" v-bind="{ 'data-slot': 'command-item', ...$attrs, ...itemAttrs }" > <slot /> </div> </ListboxItem> </template> </template>components/command/CommandList.vue <script setup lang="ts"> import { ListboxContent } from 'reka-ui'; import { cn } from '../../lib/utils'; import { type CommandListProps, injectCommand } from '.'; defineOptions({ inheritAttrs: false }); const props = withDefaults(defineProps<CommandListProps>(), { label: 'Suggestions', }); const command = injectCommand(); </script> <template> <!-- The child's attributes win over Reka's, so the list reads as cmdk's: no orientation or multiselect state. --> <ListboxContent as-child> <div v-bind="{ 'data-slot': 'command-list', ...$attrs }" :id="command.listId" cmdk-list role="listbox" tabindex="-1" :aria-label="label" :aria-orientation="undefined" :aria-multiselectable="undefined" :data-orientation="undefined" :class=" cn( 'nx:max-h-[300px] nx:overflow-y-auto nx:overflow-x-hidden nx:scroll-py-2', props.class ) " > <div cmdk-list-sizer> <slot /> </div> </div> </ListboxContent> </template>components/command/CommandLoading.vue <script setup lang="ts"> import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; export interface CommandLoadingProps { /** Loading progress from 0 to 100, when it is known. */ progress?: number; /** Accessible name for the progress bar. */ label?: string; class?: HTMLAttributes['class']; } const props = withDefaults(defineProps<CommandLoadingProps>(), { label: 'Loading...', }); </script> <template> <div data-slot="command-loading" cmdk-loading role="progressbar" :aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100" :aria-label="label" :class=" cn( 'nx:px-3 nx:py-2.5', 'nx:*:aria-hidden:flex nx:*:aria-hidden:items-center nx:*:aria-hidden:gap-3', 'nx:typography-body-default nx:text-muted-foreground', props.class ) " > <div aria-hidden="true"> <slot /> </div> </div> </template>components/command/CommandSeparator.vue <script setup lang="ts"> import { cn } from '../../lib/utils'; import { type CommandSeparatorProps, injectCommand } from '.'; const props = withDefaults(defineProps<CommandSeparatorProps>(), { alwaysRender: false, }); const command = injectCommand(); </script> <template> <div v-if="alwaysRender || !command.search.value" data-slot="command-separator" cmdk-separator role="separator" aria-hidden="true" :class=" cn('nx:-mx-1 nx:my-1 nx:h-px nx:bg-border-default-alpha', props.class) " /> </template>components/command/CommandShortcut.vue <script setup lang="ts"> import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; export interface CommandShortcutProps { class?: HTMLAttributes['class']; } const props = defineProps<CommandShortcutProps>(); </script> <template> <span data-slot="command-shortcut" :class=" cn( 'nx:ml-auto nx:typography-shortcut nx:text-muted-foreground', props.class ) " > <slot /> </span> </template>components/command/index.ts import { type ComputedRef, type HTMLAttributes, inject, type InjectionKey, type Ref, } from 'vue'; export { default as Command, type CommandProps } from './Command.vue'; export { default as CommandDialog, type CommandDialogProps, } from './CommandDialog.vue'; export { default as CommandEmpty, type CommandEmptyProps, } from './CommandEmpty.vue'; export { default as CommandGroup } from './CommandGroup.vue'; export { default as CommandInput } from './CommandInput.vue'; export { default as CommandItem } from './CommandItem.vue'; export { default as CommandList } from './CommandList.vue'; export { default as CommandLoading, type CommandLoadingProps, } from './CommandLoading.vue'; export { default as CommandSeparator } from './CommandSeparator.vue'; export { default as CommandShortcut, type CommandShortcutProps, } from './CommandShortcut.vue'; export interface CommandGroupProps { /** Heading shown above the group's items. Screen readers read it as the group's name. */ heading?: string; /** Shows the group whatever the search. */ forceMount?: boolean; class?: HTMLAttributes['class']; } export interface CommandInputProps { /** The search text. Bind it with `v-model` to read or set the search. */ modelValue?: string; class?: HTMLAttributes['class']; } export interface CommandItemProps { /** What the search matches and `select` passes. Defaults to the item's text. */ value?: string; /** More words the search matches, such as synonyms. */ keywords?: string[]; /** Shows the item dimmed and skips it in keyboard navigation. */ disabled?: boolean; /** Shows the item whatever the search. */ forceMount?: boolean; class?: HTMLAttributes['class']; } export interface CommandListProps { /** Accessible name for the list. */ label?: string; class?: HTMLAttributes['class']; } export interface CommandSeparatorProps { /** Keeps the separator while a search is active. */ alwaysRender?: boolean; class?: HTMLAttributes['class']; } /** What the search matches an item by. */ export interface CommandItemEntry { value: string; keywords: string[]; group: string | undefined; } export const CommandKey: InjectionKey<{ label: Ref<string | undefined>; search: Ref<string>; inputId: string; labelId: string; listId: string; /** How many items match the search, or every item while nothing filters. */ count: ComputedRef<number>; /** Adds an item to the search and returns a function that removes it. */ registerItem: ( id: string, entry: ComputedRef<CommandItemEntry> ) => () => void; isItemShown: (id: string) => boolean; isGroupShown: (id: string) => boolean; }> = Symbol('command'); /** The search state the Command provides to its parts. */ export function injectCommand() { const command = inject(CommandKey); if (!command) { throw new Error('Command parts must be used within a <Command />'); } return command; } export const CommandGroupKey: InjectionKey<{ id: string; forceMount: Ref<boolean>; }> = Symbol('command-group');lib/command-score.ts // cmdk's command-score, so Vue's Command matches the same items React's does. // From cmdk 1.1.1 (MIT, Copyright (c) 2022 Paco Coursey), itself after // https://github.com/superhuman/command-score (MIT). const SCORE_CONTINUE_MATCH = 1; const SCORE_SPACE_WORD_JUMP = 0.9; const SCORE_NON_SPACE_WORD_JUMP = 0.8; const SCORE_CHARACTER_JUMP = 0.17; const SCORE_TRANSPOSITION = 0.1; const PENALTY_SKIPPED = 0.999; const PENALTY_CASE_MISMATCH = 0.9999; const PENALTY_NOT_COMPLETE = 0.99; const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/; const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g; const IS_SPACE_REGEXP = /[\s-]/; const COUNT_SPACE_REGEXP = /[\s-]/g; interface Input { string: string; abbreviation: string; lowerString: string; lowerAbbreviation: string; memo: Record<string, number>; } function scoreFrom( input: Input, stringIndex: number, abbreviationIndex: number ): number { const { string, abbreviation, lowerString, lowerAbbreviation, memo } = input; if (abbreviationIndex === abbreviation.length) { return stringIndex === string.length ? SCORE_CONTINUE_MATCH : PENALTY_NOT_COMPLETE; } const key = `${stringIndex},${abbreviationIndex}`; const cached = memo[key]; if (cached !== undefined) return cached; const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); let index = lowerString.indexOf(abbreviationChar, stringIndex); let highScore = 0; while (index >= 0) { let score = scoreFrom(input, index + 1, abbreviationIndex + 1); if (score > highScore) { if (index === stringIndex) { score *= SCORE_CONTINUE_MATCH; } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { score *= SCORE_NON_SPACE_WORD_JUMP; const wordBreaks = string .slice(stringIndex, index - 1) .match(COUNT_GAPS_REGEXP); if (wordBreaks && stringIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); } } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { score *= SCORE_SPACE_WORD_JUMP; const spaceBreaks = string .slice(stringIndex, index - 1) .match(COUNT_SPACE_REGEXP); if (spaceBreaks && stringIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); } } else { score *= SCORE_CHARACTER_JUMP; if (stringIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, index - stringIndex); } } if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { score *= PENALTY_CASE_MISMATCH; } } const nextChar = lowerAbbreviation.charAt(abbreviationIndex + 1); if ( (score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === nextChar) || (nextChar === abbreviationChar && lowerString.charAt(index - 1) !== abbreviationChar) ) { const transposedScore = scoreFrom( input, index + 1, abbreviationIndex + 2 ); if (transposedScore * SCORE_TRANSPOSITION > score) { score = transposedScore * SCORE_TRANSPOSITION; } } if (score > highScore) highScore = score; index = lowerString.indexOf(abbreviationChar, index + 1); } memo[key] = highScore; return highScore; } function formatInput(value: string) { return value.toLowerCase().replace(COUNT_SPACE_REGEXP, ' '); } /** How well `search` matches `value` and its `keywords`, from 0 (no match) to 1. */ export function commandScore( value: string, search: string, keywords: string[] = [] ) { const string = keywords.length > 0 ? `${value} ${keywords.join(' ')}` : value; return scoreFrom( { string, abbreviation: search, lowerString: formatInput(string), lowerAbbreviation: formatInput(search), memo: {}, }, 0, 0 ); } - 3
Keep each file at the path shown above it.
Paths are relative to
packages/vue/src/. Keep the same folders under your@/root, and the imports between files work without edits.
Usage
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut } from '@/components/command';
Examples
Grouped
Sort commands into CommandGroup sections with headings and separators.
<script setup lang="ts">
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/command';
</script>
<template>
<Command
label="Command menu"
class="nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md"
>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>Profile<CommandShortcut>⌘P</CommandShortcut></CommandItem>
<CommandItem>Billing<CommandShortcut>⌘B</CommandShortcut></CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
Async loading
Show CommandLoading outside CommandList while results load.
<script setup lang="ts">
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
} from '@/components/command';
import { Spinner } from '@/components/spinner';
const recent = ['Recent project', 'Team dashboard'];
</script>
<template>
<Command
label="Async command menu"
class="nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md"
>
<CommandInput placeholder="Search commands..." />
<CommandLoading :progress="60" label="Loading command results">
<Spinner
aria-hidden="true"
role="presentation"
class="nx:size-3.5"
/>Loading commands...</CommandLoading
>
<CommandList>
<CommandGroup heading="Recent">
<CommandItem v-for="item in recent" :key="item" :value="item">
{{ item }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
This example also needs: Spinner.
Rich items
Give each item an icon, a description and a trailing hint.
<script setup lang="ts">
import { PhBook, PhFile, PhGear, PhUsers } from '@phosphor-icons/vue';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@/components/command';
const commands = [
{
label: 'Open project plan',
description: 'View roadmap, milestones, and project notes',
value: 'open-project-plan',
keywords: ['roadmap', 'milestones', 'docs'],
icon: PhFile,
shortcut: '⌘P',
},
{
label: 'Search documentation',
description: 'Find API references and implementation guides',
value: 'search-documentation',
keywords: ['docs', 'api', 'guide'],
icon: PhBook,
shortcut: '⌘D',
},
{
label: 'Project settings',
description: 'Update members, billing, and notifications',
value: 'project-settings',
keywords: ['preferences', 'configuration', 'admin'],
icon: PhGear,
trailing: 'Admin',
},
{
label: 'Invite teammate',
description: 'Add a member to this workspace',
value: 'invite-teammate',
keywords: ['members', 'users', 'people'],
icon: PhUsers,
},
];
</script>
<template>
<Command
label="Project command menu"
class="nx:w-[480px] nx:rounded-lg nx:border-default nx:border-border-default nx:shadow-md"
>
<CommandInput placeholder="Search project commands..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Project">
<CommandItem
v-for="command in commands"
:key="command.value"
:value="command.value"
:keywords="command.keywords"
class="nx:items-start"
>
<component
:is="command.icon"
aria-hidden="true"
class="nx:mt-0.5 nx:text-muted-foreground"
/>
<span class="nx:flex nx:min-w-0 nx:flex-1 nx:flex-col nx:gap-0.5">
<span class="nx:truncate">{{ command.label }}</span>
<span
class="nx:truncate nx:typography-body-default nx:text-muted-foreground"
>
{{ command.description }}
</span>
</span>
<CommandShortcut v-if="command.shortcut" class="nx:mt-0.5">
{{ command.shortcut }}
</CommandShortcut>
<span
v-else-if="command.trailing"
class="nx:mt-0.5 nx:ml-auto nx:shrink-0 nx:typography-label-small nx:text-muted-foreground"
>
{{ command.trailing }}
</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</template>
Props
Command
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | —No default documented | Accessible name for the search input. |
loop | boolean | false | Arrow keys wrap from the last item to the first, and back. |
shouldFilter | boolean | true | Hides the items the search doesn't match. Turn it off to filter items yourself. |
CommandDialog
| Prop | Type | Default | Description |
|---|---|---|---|
description | string | "Search for a command to run..." | Visually hidden dialog description. |
showCloseButton | boolean | false | Shows the dialog's close button. Escape and clicking outside close it either way. |
title | string | "Command Palette" | Visually hidden dialog title. Also labels the search input. |
CommandEmpty
CommandEmpty adds no props of its own; it accepts the props of the element or primitive it wraps.
CommandGroup
| Prop | Type | Default | Description |
|---|---|---|---|
forceMount | boolean | false | Shows the group whatever the search. |
heading | string | —No default documented | Heading shown above the group's items. Screen readers read it as the group's name. |
CommandInput
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | —No default documented | The search text. Bind it with |
CommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Shows the item dimmed and skips it in keyboard navigation. |
forceMount | boolean | —No default documented | Shows the item whatever the search. |
keywords | string[] | —No default documented | More words the search matches, such as synonyms. |
value | string | —No default documented | What the search matches and |
CommandList
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Suggestions" | Accessible name for the list. |
CommandLoading
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Loading..." | Accessible name for the progress bar. |
progress | number | —No default documented | Loading progress from 0 to 100, when it is known. |
CommandSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
alwaysRender | boolean | false | Keeps the separator while a search is active. |
CommandShortcut
CommandShortcut adds no props of its own; it accepts the props of the element or primitive it wraps.