Skip to main content
Nexus Design System
GitHub

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

CommandDialog props
PropTypeDefaultDescription
classNamestringNo default documented

Class names for the DialogContent.

descriptionstring"Search for a command to run..."

Visually hidden dialog description.

showCloseButtonbooleanfalse

Shows the dialog's close button. Escape and clicking outside close it either way.

titlestring"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:

    Dialog.

  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

Command props
PropTypeDefaultDescription
labelstringNo default documented

Accessible name for the search input.

loopbooleanfalse

Arrow keys wrap from the last item to the first, and back.

shouldFilterbooleantrue

Hides the items the search doesn't match. Turn it off to filter items yourself.

CommandDialog

CommandDialog props
PropTypeDefaultDescription
descriptionstring"Search for a command to run..."

Visually hidden dialog description.

showCloseButtonbooleanfalse

Shows the dialog's close button. Escape and clicking outside close it either way.

titlestring"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 props
PropTypeDefaultDescription
forceMountbooleanfalse

Shows the group whatever the search.

headingstringNo default documented

Heading shown above the group's items. Screen readers read it as the group's name.

CommandInput

CommandInput props
PropTypeDefaultDescription
modelValuestringNo default documented

The search text. Bind it with v-model to read or set the search.

CommandItem

CommandItem props
PropTypeDefaultDescription
disabledbooleanfalse

Shows the item dimmed and skips it in keyboard navigation.

forceMountbooleanNo default documented

Shows the item whatever the search.

keywordsstring[]No default documented

More words the search matches, such as synonyms.

valuestringNo default documented

What the search matches and select passes. Defaults to the item's text.

CommandList

CommandList props
PropTypeDefaultDescription
labelstring"Suggestions"

Accessible name for the list.

CommandLoading

CommandLoading props
PropTypeDefaultDescription
labelstring"Loading..."

Accessible name for the progress bar.

progressnumberNo default documented

Loading progress from 0 to 100, when it is known.

CommandSeparator

CommandSeparator props
PropTypeDefaultDescription
alwaysRenderbooleanfalse

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.