Phi
CommandPalette
@dicehub/phiv1.0.0-beta.1

CommandPalette

A composable command palette component for search and command dialogs.

<script setup>
import { computed, ref } from "vue";
import { Button } from "@dicehub/phi/components/button";
import { CommandPalette } from "@dicehub/phi/components/command-palette";
import { PhFolder, PhGear, PhHouse, PhMagnifyingGlass } from "@phosphor-icons/vue";

const open = ref(false);
const search = ref("");
const selectedItem = ref("");

const groups = [
  {
    id: "commands",
    label: "Commands",
    items: [
      { id: "new-project", title: "Create New Project", icon: PhFolder },
      { id: "settings", title: "Open Settings", icon: PhGear },
      { id: "search", title: "Search Files", icon: PhMagnifyingGlass },
    ],
  },
  {
    id: "pages",
    label: "Pages",
    items: [
      { id: "home", title: "Home", icon: PhHouse },
      { id: "dashboard", title: "Dashboard" },
      { id: "users", title: "Users" },
    ],
  },
];

const filteredGroups = computed(() =>
  groups
    .map((group) => ({
      ...group,
      items: group.items.filter((item) =>
        item.title.toLowerCase().includes(search.value.toLowerCase()),
      ),
    }))
    .filter((group) => group.items.length),
);

const getSelectableItems = (groups) => groups.flatMap((group) => group.items);

const selectItem = (item) => {
  selectedItem.value = item.title;
  open.value = false;
  search.value = "";
};
</script>

<template>
  <div class="flex flex-col items-start gap-4">
    <Button @click="open = true">Open Command Palette</Button>
    <p v-if="selectedItem">Last selected: {{ selectedItem }}</p>

    <CommandPalette.Root
      v-model:open="open"
      v-model:value="search"
      :filter="false"
      :get-selectable-items="getSelectableItems"
      :items="filteredGroups"
      :item-to-string-value="(group) => group.label"
      @select="selectItem"
    >
      <CommandPalette.Input placeholder="Type a command or search..." />
      <CommandPalette.List>
        <CommandPalette.Results v-slot="{ item: group }">
          <CommandPalette.Group :items="group.items">
            <CommandPalette.GroupLabel>{{ group.label }}</CommandPalette.GroupLabel>
            <CommandPalette.Items v-slot="{ item }">
              <CommandPalette.Item :value="item">
                <span class="flex items-center gap-3">
                  <component :is="item.icon" v-if="item.icon" :size="16" />
                  <span>{{ item.title }}</span>
                </span>
              </CommandPalette.Item>
            </CommandPalette.Items>
          </CommandPalette.Group>
        </CommandPalette.Results>
        <CommandPalette.Empty>No commands found</CommandPalette.Empty>
      </CommandPalette.List>
      <CommandPalette.Footer>
        <span><kbd>↑↓</kbd> Navigate</span>
        <span><kbd>↵</kbd> Select</span>
      </CommandPalette.Footer>
    </CommandPalette.Root>
  </div>
</template>

Installation

Barrel

import { CommandPalette } from "@dicehub/phi";

Granular

import { CommandPalette } from "@dicehub/phi/components/command-palette";

Usage

CommandPalette is a compound component built on Ark UI's Combobox and Dialog primitives. It provides accessible keyboard navigation and customizable styling for command palette interfaces.

<script setup>
import { ref } from "vue";
import { CommandPalette } from "@dicehub/phi/components/command-palette";

const open = ref(false);
const search = ref("");
const items = [
  { id: "create", title: "Create Project" },
  { id: "settings", title: "Open Settings" },
];
</script>

<template>
  <button @click="open = true">Open</button>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="search"
    :get-selectable-items="(items) => items"
    :items="items"
    :item-to-string-value="(item) => item.title"
    @select="open = false"
  >
    <CommandPalette.Input placeholder="Search..." />
    <CommandPalette.List>
      <CommandPalette.Results v-slot="{ item }">
        <CommandPalette.Item :value="item">{{ item.title }}</CommandPalette.Item>
      </CommandPalette.Results>
      <CommandPalette.Empty>No results</CommandPalette.Empty>
    </CommandPalette.List>
  </CommandPalette.Root>
</template>

Keyboard Navigation

Built-in keyboard navigation is provided automatically:

↑↓Move highlight between items
EnterSelect highlighted item
⌘/CtrlEnterSelect with newTab: true
EscapeClose the dialog

Examples

With Grouped Items

Group related commands together with labels.

<script setup>
import { computed, ref } from "vue";
import { Button } from "@dicehub/phi/components/button";
import { CommandPalette } from "@dicehub/phi/components/command-palette";
import { PhFolder, PhGear, PhHouse, PhMagnifyingGlass } from "@phosphor-icons/vue";

const open = ref(false);
const search = ref("");
const selectedItem = ref("");

const groups = [
  {
    id: "commands",
    label: "Commands",
    items: [
      { id: "new-project", title: "Create New Project", icon: PhFolder },
      { id: "settings", title: "Open Settings", icon: PhGear },
      { id: "search", title: "Search Files", icon: PhMagnifyingGlass },
    ],
  },
  {
    id: "pages",
    label: "Pages",
    items: [
      { id: "home", title: "Home", icon: PhHouse },
      { id: "dashboard", title: "Dashboard" },
      { id: "users", title: "Users" },
    ],
  },
];

const filteredGroups = computed(() =>
  groups
    .map((group) => ({
      ...group,
      items: group.items.filter((item) =>
        item.title.toLowerCase().includes(search.value.toLowerCase()),
      ),
    }))
    .filter((group) => group.items.length),
);

const getSelectableItems = (groups) => groups.flatMap((group) => group.items);

const selectItem = (item) => {
  selectedItem.value = item.title;
  open.value = false;
  search.value = "";
};
</script>

<template>
  <div class="flex flex-col items-start gap-4">
    <Button @click="open = true">Open Command Palette</Button>
    <p v-if="selectedItem">Last selected: {{ selectedItem }}</p>

    <CommandPalette.Root
      v-model:open="open"
      v-model:value="search"
      :filter="false"
      :get-selectable-items="getSelectableItems"
      :items="filteredGroups"
      :item-to-string-value="(group) => group.label"
      @select="selectItem"
    >
      <CommandPalette.Input placeholder="Type a command or search..." />
      <CommandPalette.List>
        <CommandPalette.Results v-slot="{ item: group }">
          <CommandPalette.Group :items="group.items">
            <CommandPalette.GroupLabel>{{ group.label }}</CommandPalette.GroupLabel>
            <CommandPalette.Items v-slot="{ item }">
              <CommandPalette.Item :value="item">
                <span class="flex items-center gap-3">
                  <component :is="item.icon" v-if="item.icon" :size="16" />
                  <span>{{ item.title }}</span>
                </span>
              </CommandPalette.Item>
            </CommandPalette.Items>
          </CommandPalette.Group>
        </CommandPalette.Results>
        <CommandPalette.Empty>No commands found</CommandPalette.Empty>
      </CommandPalette.List>
      <CommandPalette.Footer>
        <span><kbd>↑↓</kbd> Navigate</span>
        <span><kbd>↵</kbd> Select</span>
      </CommandPalette.Footer>
    </CommandPalette.Root>
  </div>
</template>

Simple Flat List

For simpler use cases, use a flat array of items without grouping.

<script setup>
import { computed, ref } from "vue";
import { Button } from "@dicehub/phi/components/button";
import { CommandPalette } from "@dicehub/phi/components/command-palette";

const open = ref(false);
const search = ref("");
const actions = [
  { id: "copy", title: "Copy" },
  { id: "paste", title: "Paste" },
  { id: "cut", title: "Cut" },
  { id: "delete", title: "Delete" },
  { id: "select-all", title: "Select All" },
];

const filteredActions = computed(() =>
  actions.filter((item) =>
    item.title.toLowerCase().includes(search.value.toLowerCase()),
  ),
);
</script>

<template>
  <Button @click="open = true">Open Simple Palette</Button>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="search"
    :filter="false"
    :get-selectable-items="(items) => items"
    :items="filteredActions"
    :item-to-string-value="(item) => item.title"
    @select="open = false"
  >
    <CommandPalette.Input placeholder="Search actions..." />
    <CommandPalette.List>
      <CommandPalette.Results v-slot="{ item }">
        <CommandPalette.Item :value="item">{{ item.title }}</CommandPalette.Item>
      </CommandPalette.Results>
      <CommandPalette.Empty>No actions found</CommandPalette.Empty>
    </CommandPalette.List>
  </CommandPalette.Root>
</template>

Loading State

Show a loading spinner while fetching results.

<script setup>
import { computed, ref } from "vue";
import { Button } from "@dicehub/phi/components/button";
import { CommandPalette } from "@dicehub/phi/components/command-palette";

const open = ref(false);
const loading = ref(false);
const search = ref("");
const groups = [
  { id: "commands", label: "Commands", items: [{ id: "settings", title: "Open Settings" }] },
  { id: "pages", label: "Pages", items: [{ id: "dashboard", title: "Dashboard" }] },
];

const filteredGroups = computed(() =>
  groups.map((group) => ({
    ...group,
    items: group.items.filter((item) =>
      item.title.toLowerCase().includes(search.value.toLowerCase()),
    ),
  })).filter((group) => group.items.length),
);

const openPalette = () => {
  open.value = true;
  loading.value = true;
  window.setTimeout(() => {
    loading.value = false;
  }, 1500);
};
</script>

<template>
  <Button @click="openPalette">Open with Loading</Button>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="search"
    :filter="false"
    :get-selectable-items="(groups) => groups.flatMap((group) => group.items)"
    :items="loading ? [] : filteredGroups"
  >
    <CommandPalette.Input placeholder="Search..." />
    <CommandPalette.List>
      <CommandPalette.Loading v-if="loading" />
      <template v-else>
        <CommandPalette.Results v-slot="{ item: group }">
          <CommandPalette.Group :items="group.items">
            <CommandPalette.GroupLabel>{{ group.label }}</CommandPalette.GroupLabel>
            <CommandPalette.Items v-slot="{ item }">
              <CommandPalette.Item :value="item">{{ item.title }}</CommandPalette.Item>
            </CommandPalette.Items>
          </CommandPalette.Group>
        </CommandPalette.Results>
        <CommandPalette.Empty>No results found</CommandPalette.Empty>
      </template>
    </CommandPalette.List>
  </CommandPalette.Root>
</template>

Disabling Browser Autocomplete

Pass standard HTML input attributes to suppress browser and password manager autocomplete overlays.

<CommandPalette.Input
  autocomplete="off"
  autocapitalize="none"
  autocorrect="off"
  data-1p-ignore="true"
  data-lpignore="true"
  placeholder="Search commands..."
  :spellcheck="false"
/>

ResultItem with Breadcrumbs

Use ResultItem for rich items with breadcrumbs, icons, and optional text highlighting.

<script setup>
import { ref } from "vue";
import { Button } from "@dicehub/phi/components/button";
import { CommandPalette } from "@dicehub/phi/components/command-palette";
import { PhFile } from "@phosphor-icons/vue";

const open = ref(false);
const search = ref("");
const results = [
  { id: "button", title: "Button", breadcrumbs: ["Components"] },
  { id: "dialog", title: "Dialog", breadcrumbs: ["Components"] },
  { id: "page-header", title: "Page Header", breadcrumbs: ["Blocks"] },
];
</script>

<template>
  <Button @click="open = true">Open with ResultItem</Button>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="search"
    :get-selectable-items="(items) => items"
    :items="results"
    :item-to-string-value="(item) => item.title"
    @select="open = false"
  >
    <CommandPalette.Input placeholder="Search documentation..." />
    <CommandPalette.List>
      <CommandPalette.Results v-slot="{ item }">
        <CommandPalette.ResultItem
          :breadcrumbs="item.breadcrumbs"
          :title="item.title"
          :value="item"
        >
          <template #icon><PhFile :size="16" /></template>
        </CommandPalette.ResultItem>
      </CommandPalette.Results>
      <CommandPalette.Empty>No pages found</CommandPalette.Empty>
    </CommandPalette.List>
    <CommandPalette.Footer>
      <span><kbd>↑↓</kbd> Navigate</span>
      <span><kbd>⌘↵</kbd> Open in new tab</span>
    </CommandPalette.Footer>
  </CommandPalette.Root>
</template>

Component Parts

CommandPalette.Root

The main wrapper that combines Dialog and Panel. Manages open state and Combobox behavior.

CommandPalette.Dialog

Modal dialog wrapper. Use with Panel for swappable content, such as drill-down navigation.

CommandPalette.Panel

Combobox panel without the dialog wrapper. Use inside Dialog when content can swap without remounting.

CommandPalette.Input

Search input field with autofocus and keyboard handling.

CommandPalette.List

Scrollable container for results.

CommandPalette.Results

Slot iterator for items or groups.

CommandPalette.Group

Category grouping container.

CommandPalette.GroupLabel

Section header text within a group.

CommandPalette.Items

Slot iterator for items within a group.

CommandPalette.Item

Basic selectable item.

CommandPalette.ResultItem

Rich item with breadcrumbs, icons, and text highlighting.

CommandPalette.HighlightedText

Renders text with highlighted portions based on match indices.

CommandPalette.Empty

Empty state when no results match.

CommandPalette.Loading

Loading spinner state.

Footer for keyboard hints or other content.

API Reference

CommandPalette.Root Props

interface CommandPaletteRootProps<TGroup, TItem = TGroup> {
  // Dialog state
  open?: boolean;
  defaultValue?: string;
  value?: string;

  // Combobox
  items: TGroup[];
  filter?: false | ((item: TGroup, query: string) => boolean);
  itemToStringValue?: (item: TGroup) => string;
  getSelectableItems?: (items: TGroup[]) => TItem[];
}

type CommandPaletteRootEmits<TItem> = {
  "update:open": [open: boolean];
  "update:value": [value: string];
  openChange: [open: boolean];
  valueChange: [value: string];
  select: [item: TItem, options: { newTab: boolean }];
  itemHighlighted: [item: TItem | undefined, details: { index: number; reason: string }];
  close: [];
};

CommandPalette.ResultItem Props

interface CommandPaletteResultItemProps<T> {
  value: T;
  title: string;
  breadcrumbs?: string[];
  titleHighlights?: [number, number][];
  breadcrumbHighlights?: [number, number][][];
  description?: string;
  showArrow?: boolean; // default: true
  external?: boolean;
  nonInteractive?: boolean;
}