Skip to content
Kappa
Command Palette
@dicehub/kappav0.5.0

Command Palette

Search and run commands from an accessible modal combobox.

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

const open = ref(false);
const query = ref("");
const selected = ref("");
const groups = [
  {
    label: "Commands",
    items: [
      { id: "create", title: "Create new project" },
      { id: "settings", title: "Open settings" },
      { id: "search", title: "Search files" },
    ],
  },
  {
    label: "Pages",
    items: [
      { id: "home", title: "Home" },
      { id: "dashboard", title: "Dashboard" },
    ],
  },
];
const filteredGroups = computed(() => groups
  .map((group) => ({
    ...group,
    items: group.items.filter((item) =>
      item.title.toLowerCase().includes(query.value.toLowerCase()),
    ),
  }))
  .filter((group) => group.items.length));
const flatten = (items) => items.flatMap((group) => group.items);
const title = (item) => item.title;
const select = (item) => {
  selected.value = item.title;
  query.value = "";
  open.value = false;
};
</script>

<template>
  <Button @click="open = true">Open Command Palette</Button>
  <p v-if="selected">Last selected: {{ selected }}</p>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="query"
    :filter="false"
    :get-selectable-items="flatten"
    :item-to-string-value="title"
    :items="filteredGroups"
    @select="select"
  >
    <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">{{ item.title }}</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>Enter</kbd> Select</span>
    </CommandPalette.Footer>
  </CommandPalette.Root>
</template>

Installation

Barrel

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

Granular

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

Usage

CommandPalette is a compound component built on Ark UI Dialog and Combobox primitives. Pass a flat item array for built-in filtering, or control the query and flatten visible groups with getSelectableItems.

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

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

<template>
  <button @click="open = true">Open</button>
  <CommandPalette.Root v-model:open="open" :items="commands" @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>

Composition

Typical part hierarchy
CommandPalette.Root
└── CommandPalette.Dialog
    └── CommandPalette.Panel
        ├── CommandPalette.Input
        ├── CommandPalette.List
        │   ├── CommandPalette.Empty
        │   ├── CommandPalette.Loading
        │   └── CommandPalette.Results
        │       └── CommandPalette.Group
        │           ├── CommandPalette.GroupLabel
        │           └── CommandPalette.Items
        │               └── CommandPalette.Item
        └── CommandPalette.Footer

Root provides the usual modal command surface. Dialog and Panel are also public so an application can keep one modal mounted while it swaps command levels or result sources.

<CommandPalette.Root>
  <CommandPalette.Dialog>
    <CommandPalette.Panel>
      <CommandPalette.Input />
      <CommandPalette.List>
        <CommandPalette.Empty />
        <CommandPalette.Results>
          <CommandPalette.Group>
            <CommandPalette.GroupLabel />
            <CommandPalette.Items>
              <CommandPalette.Item />
            </CommandPalette.Items>
          </CommandPalette.Group>
        </CommandPalette.Results>
      </CommandPalette.List>
      <CommandPalette.Footer />
    </CommandPalette.Panel>
  </CommandPalette.Dialog>
</CommandPalette.Root>

Flat lists

Render Results directly. Object items use itemToStringValue when their searchable label is not one of the default keys.

Groups and collections

Render Group, GroupLabel, and Items for each visible group. Flatten only visible group items for keyboard navigation.

Single activation

Unlike a multi-select Combobox, a command palette runs one highlighted command. Chips, multiple selection, and a clear-selection control do not apply.

Keyboard Navigation

Built-in keyboard navigation follows the active Combobox collection:

↑↓Move the highlight between enabled commands.
HomeEndMove to the first or last enabled command.
EnterRun the highlighted command.
⌘/CtrlEnterRun with newTab set to true.
EscapeClose the dialog and restore focus.

Examples

With Grouped Items

Group related commands with labels. The first enabled command is highlighted automatically, and disabled commands remain visible but are skipped.

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

const open = ref(false);
const query = ref("");
const selected = ref("");
const groups = [
  {
    label: "Commands",
    items: [
      { id: "create", title: "Create new project" },
      { id: "settings", title: "Open settings" },
      { id: "search", title: "Search files" },
    ],
  },
  {
    label: "Pages",
    items: [
      { id: "home", title: "Home" },
      { id: "dashboard", title: "Dashboard" },
    ],
  },
];
const filteredGroups = computed(() => groups
  .map((group) => ({
    ...group,
    items: group.items.filter((item) =>
      item.title.toLowerCase().includes(query.value.toLowerCase()),
    ),
  }))
  .filter((group) => group.items.length));
const flatten = (items) => items.flatMap((group) => group.items);
const title = (item) => item.title;
const select = (item) => {
  selected.value = item.title;
  query.value = "";
  open.value = false;
};
</script>

<template>
  <Button @click="open = true">Open Command Palette</Button>
  <p v-if="selected">Last selected: {{ selected }}</p>
  <CommandPalette.Root
    v-model:open="open"
    v-model:value="query"
    :filter="false"
    :get-selectable-items="flatten"
    :item-to-string-value="title"
    :items="filteredGroups"
    @select="select"
  >
    <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">{{ item.title }}</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>Enter</kbd> Select</span>
    </CommandPalette.Footer>
  </CommandPalette.Root>
</template>

Simple Flat List

Use a flat array for a compact command list. Built-in filtering uses title, label, name, or value text.

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

const open = ref(false);
const actions = [
  { id: "copy", title: "Copy" },
  { id: "paste", title: "Paste" },
  { id: "cut", title: "Cut" },
  { id: "delete", title: "Delete" },
];
</script>

<template>
  <button @click="open = true">Open Simple Palette</button>
  <CommandPalette.Root v-model:open="open" :items="actions" @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

Replace results with an accessible loading status while commands load from a local or remote index.

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

const open = ref(false);
const loading = ref(false);
const items = ref([]);
const openPalette = async () => {
  open.value = true;
  loading.value = true;
  items.value = await loadCommands();
  loading.value = false;
};
</script>

<template>
  <button @click="openPalette">Open with Loading</button>
  <CommandPalette.Root v-model:open="open" :items="loading ? [] : items">
    <CommandPalette.Input placeholder="Search..." />
    <CommandPalette.List>
      <CommandPalette.Loading v-if="loading" />
      <template v-else>
        <CommandPalette.Results v-slot="{ item }">
          <CommandPalette.Item :value="item">{{ item.title }}</CommandPalette.Item>
        </CommandPalette.Results>
        <CommandPalette.Empty>No results found.</CommandPalette.Empty>
      </template>
    </CommandPalette.List>
  </CommandPalette.Root>
</template>

Disabling Browser Autocomplete

Forward standard input and password-manager attributes when browser overlays would compete with palette results.

<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 object items that need breadcrumbs, descriptions, icons, match highlights, or destination cues.

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

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

<template>
  <button @click="open = true">Open with ResultItem</button>
  <CommandPalette.Root v-model:open="open" :items="results" @select="open = false">
    <CommandPalette.Input placeholder="Search documentation..." />
    <CommandPalette.List>
      <CommandPalette.Results v-slot="{ item }">
        <CommandPalette.ResultItem
          :breadcrumbs="item.breadcrumbs"
          :description="item.description"
          :title="item.title"
          :value="item"
        />
      </CommandPalette.Results>
      <CommandPalette.Empty>No pages found.</CommandPalette.Empty>
    </CommandPalette.List>
  </CommandPalette.Root>
</template>

Click the search field, or press / while it has focus. The panel expands and fades in at the top of the screen. Reduced motion removes the animation.

<script setup lang="ts">
import { ref } from "vue";
import { Search } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { CommandPalette } from "@dicehub/kappa/components/command-palette";

const container = ref<HTMLElement>();
const open = ref(false);
const query = ref("");
const selected = ref("");
const pages = ["Dashboard", "Projects", "Recently opened", "Activities", "Settings"];
function showSearch() {
  container.value?.querySelector<HTMLElement>("[data-search-trigger]")?.focus();
  query.value = "";
  open.value = true;
}
function shortcut(event: KeyboardEvent) {
  if (event.key !== "/" || event.defaultPrevented || event.repeat || event.isComposing || event.metaKey || event.ctrlKey || event.altKey || open.value) return;
  const target = event.composedPath().find(node => node instanceof HTMLElement) as HTMLElement | undefined;
  if (target?.isContentEditable || target?.closest('input, textarea, select, [role="textbox"], [role="combobox"], [role="searchbox"], [role="dialog"], [role="menu"]')) return;
  event.preventDefault();
  event.stopPropagation();
  showSearch();
}
</script>

<template>
  <div ref="container" class="command-palette-top-search-demo" @keydown="shortcut">
    <Button variant="ghost" :icon="Search" class="command-palette-top-search-demo__trigger" data-search-trigger aria-label="Search or go to…" aria-keyshortcuts="/" aria-haspopup="dialog" :aria-expanded="open" @click="showSearch">
      <span>Search or go to…</span><kbd aria-hidden="true">/</kbd>
    </Button>
    <p v-if="selected" role="status">Opened {{ selected }}.</p>
    <CommandPalette.Dialog v-model:open="open" class="command-palette-top-search-dialog" aria-label="Top-aligned search">
      <CommandPalette.Panel v-model:value="query" :open="open" :items="pages" @close="open = false" @select="item => { selected = String(item); open = false; }">
        <CommandPalette.Input placeholder="Search or go to…" />
        <CommandPalette.List>
          <CommandPalette.Results v-slot="{ item }"><CommandPalette.Item :value="item">{{ item }}</CommandPalette.Item></CommandPalette.Results>
          <CommandPalette.Empty>No results found.</CommandPalette.Empty>
        </CommandPalette.List>
        <CommandPalette.Footer><span>↑↓ Navigate</span><span>Enter Open</span><span>Esc Close</span></CommandPalette.Footer>
      </CommandPalette.Panel>
    </CommandPalette.Dialog>
  </div>
</template>

<style>
.command-palette-top-search-demo { inline-size: 21.5rem; max-inline-size: 100%; margin-inline: auto; }
.command-palette-top-search-demo__trigger.kappa-button { inline-size: 100%; min-inline-size: 0; block-size: 2rem; min-block-size: 2rem; border: 1px solid var(--kappa-line); border-radius: 0.75rem; background: color-mix(in srgb, var(--kappa-control) 25%, var(--kappa-tint)); color: var(--kappa-subtle); }
.command-palette-top-search-demo__trigger.kappa-button[data-variant="ghost"] { --kappa-button-hover-background: color-mix(in srgb, var(--kappa-control) 25%, var(--kappa-tint)); --kappa-button-hover-foreground: var(--kappa-subtle); }
.command-palette-top-search-demo__trigger.kappa-button:hover { border-color: color-mix(in srgb, var(--kappa-line) 90%, var(--kappa-subtle)); }
.command-palette-top-search-demo__trigger .kappa-button__label { display: flex; flex: 1; align-items: center; gap: 0.5rem; min-inline-size: 0; }
.command-palette-top-search-demo__trigger .kappa-button__label > span { flex: 1; overflow: hidden; text-align: start; text-overflow: ellipsis; white-space: nowrap; }
.command-palette-top-search-demo__trigger kbd { display: grid; place-items: center; min-inline-size: 1.25rem; block-size: 1.25rem; border: 1px solid var(--kappa-line); border-radius: 0.1875rem; font: 0.75rem / 1 var(--kappa-font-mono); }
.kappa-command-palette__positioner:has(> .command-palette-top-search-dialog) { padding: 0.25rem 0.5rem 0.5rem; }
.command-palette-top-search-dialog.kappa-command-palette__content { inline-size: min(48rem, calc(100vw - 1rem)); max-block-size: calc(100dvh - 0.75rem); border: 0; border-radius: 1rem; transform-origin: center top; }
.command-palette-top-search-dialog .kappa-command-palette__input-header { min-block-size: 2.5rem; margin: 0.25rem; padding-inline: 0.5rem; gap: 0.5rem; border: 0; border-radius: 0.75rem; background: var(--kappa-control); }
.command-palette-top-search-dialog .kappa-command-palette__input-header:focus-within { box-shadow: inset 0 0 0 2px var(--kappa-focus); }
.command-palette-top-search-dialog .kappa-command-palette__input { font-size: 0.875rem; }
.command-palette-top-search-dialog .kappa-command-palette__list { border-block-start: 1px solid var(--kappa-line); }
.command-palette-top-search-dialog.kappa-command-palette__content[data-state="open"] { animation: command-palette-top-search-in 200ms cubic-bezier(0.22, 0.61, 0.36, 1); }
.command-palette-top-search-dialog.kappa-command-palette__content[data-state="closed"] { animation: command-palette-top-search-out 200ms cubic-bezier(0.22, 0.61, 0.36, 1); }
.kappa-command-palette__backdrop:has(+ .kappa-command-palette__positioner > .command-palette-top-search-dialog) { background: color-mix(in srgb, var(--kappa-default) 24%, transparent); backdrop-filter: none; animation-duration: 200ms; }
@keyframes command-palette-top-search-in { from { opacity: 0; scale: 0.8; } to { opacity: 1; scale: 1; } }
@keyframes command-palette-top-search-out { from { opacity: 1; scale: 1; } to { opacity: 0; scale: 0.8; } }
@media (max-width: 767px) {
  .command-palette-top-search-dialog .kappa-command-palette__input { font-size: 1rem; }
}
</style>

Click the search field, or press / while it has focus. The field expands into a search panel and returns when closed. Reduced motion opens it without animation.

<script setup lang="ts">
import { ref } from "vue";
import { Search } from "@lucide/vue";
import { Button } from "@dicehub/kappa/components/button";
import { CommandPalette } from "@dicehub/kappa/components/command-palette";

const container = ref<HTMLElement>();
const body = ref<HTMLElement>();
const open = ref(false);
const query = ref("");
const selected = ref("");
const motion = ref<Record<string, string>>({});
const pages = ["Dashboard", "Projects", "Recently opened", "Activities", "Settings"];

function rectangleProperties(prefix: string, rectangle: DOMRect) {
  return {
    [`--kappa-morph-${prefix}-width`]: `${rectangle.width}px`,
    [`--kappa-morph-${prefix}-height`]: `${rectangle.height}px`,
    [`--kappa-morph-${prefix}-x`]: `${rectangle.x + rectangle.width / 2 - window.innerWidth / 2}px`,
    [`--kappa-morph-${prefix}-y`]: `${rectangle.y - 4}px`,
  };
}
function setOpen(next: boolean) {
  if (next === open.value) return;
  const trigger = container.value?.querySelector<HTMLElement>("[data-morph-search-trigger]");
  if (!trigger) return;
  const panel = body.value?.closest<HTMLElement>(".command-palette-morph-dialog");
  const origin = trigger.getBoundingClientRect();
  const current = panel?.getBoundingClientRect() ?? origin;
  const appearance = panel ? getComputedStyle(panel) : undefined;
  motion.value = {
    ...rectangleProperties("start", current),
    ...rectangleProperties("origin", origin),
    "--kappa-morph-start-radius": appearance?.borderRadius ?? "0.75rem",
    "--kappa-morph-start-opacity": appearance?.opacity ?? "1",
  };
  if (next) {
    trigger.focus();
    query.value = "";
  }
  open.value = next;
}
function select(item: unknown) {
  selected.value = String(item);
  setOpen(false);
}
function shortcut(event: KeyboardEvent) {
  if (event.key !== "/" || event.defaultPrevented || event.repeat || event.isComposing || event.metaKey || event.ctrlKey || event.altKey || open.value) return;
  if (document.querySelector('[role="dialog"][data-state="open"], [role="menu"][data-state="open"]')) return;
  const target = event.composedPath().find(node => node instanceof HTMLElement) as HTMLElement | undefined;
  if (target?.isContentEditable || target?.closest('input, textarea, select, [role="textbox"], [role="combobox"], [role="searchbox"], [role="dialog"], [role="menu"], [role="listbox"]')) return;
  event.preventDefault();
  event.stopPropagation();
  setOpen(true);
}
</script>

<template>
  <div ref="container" class="command-palette-morph-demo" @keydown="shortcut">
    <Button variant="ghost" :icon="Search" class="command-palette-morph-demo__trigger" data-morph-search-trigger aria-label="Search or go to…" aria-keyshortcuts="/" aria-haspopup="dialog" :aria-expanded="open" @click="setOpen(true)">
      <span>Search or go to…</span><kbd aria-hidden="true">/</kbd>
    </Button>
    <p class="command-palette-morph-demo__status" role="status">{{ selected ? `Opened ${selected}.` : "" }}</p>
    <CommandPalette.Dialog :open="open" :style="motion" class="command-palette-morph-dialog" aria-label="Morphing search" @update:open="setOpen">
      <div ref="body" class="command-palette-morph-dialog__body">
        <CommandPalette.Panel v-model:value="query" :open="open" :items="pages" @close="setOpen(false)" @select="select">
          <CommandPalette.Input placeholder="Type to search…" />
          <CommandPalette.List>
            <CommandPalette.Results v-slot="{ item }"><CommandPalette.Item :value="item">{{ item }}</CommandPalette.Item></CommandPalette.Results>
            <CommandPalette.Empty>No results found.</CommandPalette.Empty>
          </CommandPalette.List>
          <CommandPalette.Footer><span>↑↓ Navigate</span><span>Enter Open</span><span>Esc Close</span></CommandPalette.Footer>
        </CommandPalette.Panel>
      </div>
    </CommandPalette.Dialog>
  </div>
</template>

<style>
.command-palette-morph-demo { inline-size: 21.5rem; max-inline-size: 100%; margin-inline: auto; }
.command-palette-morph-demo__trigger.kappa-button { inline-size: 100%; min-inline-size: 0; block-size: 2rem; min-block-size: 2rem; border: 1px solid var(--kappa-line); border-radius: 0.75rem; background: color-mix(in srgb, var(--kappa-control) 50%, var(--kappa-tint)); color: var(--kappa-subtle); }
.command-palette-morph-demo__trigger.kappa-button[data-variant="ghost"] { --kappa-button-hover-background: color-mix(in srgb, var(--kappa-control) 50%, var(--kappa-tint)); --kappa-button-hover-foreground: var(--kappa-subtle); }
.command-palette-morph-demo__trigger.kappa-button:hover { border-color: color-mix(in srgb, var(--kappa-line) 90%, var(--kappa-subtle)); }
.command-palette-morph-demo__trigger .kappa-button__label { display: flex; flex: 1; align-items: center; gap: 0.5rem; min-inline-size: 0; }
.command-palette-morph-demo__trigger .kappa-button__label > span { flex: 1; overflow: hidden; text-align: start; text-overflow: ellipsis; white-space: nowrap; }
.command-palette-morph-demo__trigger kbd { display: grid; place-items: center; min-inline-size: 1.25rem; block-size: 1.25rem; border: 1px solid var(--kappa-line); border-radius: 0.1875rem; font: 0.75rem / 1 var(--kappa-font-mono); }
.command-palette-morph-demo__status { block-size: 1.5rem; margin: 0.5rem 0 0; overflow: hidden; color: var(--kappa-subtle); font-size: 0.875rem; line-height: 1.5rem; white-space: nowrap; text-overflow: ellipsis; }
.kappa-command-palette__positioner:has(> .command-palette-morph-dialog) { padding: 0.25rem 0.5rem 0.5rem; }
.command-palette-morph-dialog.kappa-command-palette__content { inline-size: min(48rem, calc(100vw - 1rem)); block-size: min(20rem, calc(100dvh - 0.75rem)); max-block-size: calc(100dvh - 0.75rem); border: 0; border-radius: 1rem; }
.command-palette-morph-dialog__body { display: flex; flex: 1; flex-direction: column; min-inline-size: 0; min-block-size: 0; }
.command-palette-morph-dialog .kappa-command-palette__input-header { min-block-size: 2.5rem; margin: 0.25rem; padding-inline: 0.5rem; gap: 0.5rem; border: 0; border-radius: 0.75rem; background: var(--kappa-control); }
.command-palette-morph-dialog .kappa-command-palette__input-header:focus-within { box-shadow: inset 0 0 0 2px var(--kappa-focus); }
.command-palette-morph-dialog .kappa-command-palette__input { font-size: 0.875rem; }
.command-palette-morph-dialog .kappa-command-palette__list { border-block-start: 1px solid var(--kappa-line); }
.command-palette-morph-dialog.kappa-command-palette__content[data-state="open"] { animation: command-palette-morph-in 200ms cubic-bezier(0.22, 0.61, 0.36, 1); }
.command-palette-morph-dialog.kappa-command-palette__content[data-state="closed"] { animation: command-palette-morph-out 200ms cubic-bezier(0.22, 0.61, 0.36, 1); }
.command-palette-morph-dialog[data-state="open"] :is(.kappa-command-palette__list, .kappa-command-palette__footer) { animation: command-palette-morph-results-in 200ms ease-out; }
.kappa-command-palette__backdrop:has(+ .kappa-command-palette__positioner > .command-palette-morph-dialog) { background: color-mix(in srgb, var(--kappa-default) 24%, transparent); backdrop-filter: none; animation-duration: 200ms; }
@keyframes command-palette-morph-in {
  from { inline-size: var(--kappa-morph-start-width); block-size: var(--kappa-morph-start-height); translate: var(--kappa-morph-start-x) var(--kappa-morph-start-y); border-radius: var(--kappa-morph-start-radius); opacity: var(--kappa-morph-start-opacity); }
  to { inline-size: min(48rem, calc(100vw - 1rem)); block-size: min(20rem, calc(100dvh - 0.75rem)); translate: 0 0; border-radius: 1rem; opacity: 1; }
}
@keyframes command-palette-morph-out {
  from { inline-size: var(--kappa-morph-start-width); block-size: var(--kappa-morph-start-height); translate: var(--kappa-morph-start-x) var(--kappa-morph-start-y); border-radius: var(--kappa-morph-start-radius); opacity: var(--kappa-morph-start-opacity); }
  to { inline-size: var(--kappa-morph-origin-width); block-size: var(--kappa-morph-origin-height); translate: var(--kappa-morph-origin-x) var(--kappa-morph-origin-y); border-radius: 0.75rem; opacity: 0; }
}
@keyframes command-palette-morph-results-in { from, 35% { opacity: 0; } to { opacity: 1; } }
@media (max-width: 767px) {
  .command-palette-morph-dialog .kappa-command-palette__input { font-size: 1rem; }
}
@media (prefers-reduced-motion: reduce) {
  .command-palette-morph-dialog, .command-palette-morph-dialog :is(.kappa-command-palette__list, .kappa-command-palette__footer) { animation: none !important; }
}
</style>

Component Parts

Dialog accepts ids for its parts. Use :ids="{ content: 'workspace-search' }" and set the opening button's aria-controls="workspace-search".

CommandPalette.Root

Combines Dialog and Panel and owns controlled or uncontrolled state.

CommandPalette.Dialog

Provides the modal layer, focus trap, dismissal, portal, and focus restoration.

CommandPalette.Panel

Provides the Combobox collection, filtering, highlighting, and selection state.

CommandPalette.Input

Forwards input attributes and handles search and keyboard activation.

CommandPalette.List

Contains the scrollable result area.

CommandPalette.Results

Iterates the visible top-level items or groups.

CommandPalette.Group

Groups related commands and supplies items to Items.

CommandPalette.GroupLabel

Labels a command group.

CommandPalette.Items

Iterates commands inside the nearest Group.

CommandPalette.Item

Provides one selectable or disabled command.

CommandPalette.ResultItem

Adds breadcrumbs, description, icon, highlights, and destination cues.

CommandPalette.HighlightedText

Marks inclusive match ranges without using HTML injection.

CommandPalette.Empty

Displays the no-results state.

CommandPalette.Loading

Displays an accessible loading status.

Contains keyboard help or related status information.

Accessibility

  • Dialog traps focus, blocks background interaction, and restores focus after dismissal.
  • Closing panels cannot receive focus during an exit animation.
  • The search input receives initial focus and has a configurable accessible name.
  • Disabled commands remain perceivable and are skipped during keyboard navigation.
  • Empty and Loading expose complete no-result and progress states.
  • Overlay and result motion honor prefers-reduced-motion.

API Reference

Root

PropTypeDefaultDescription
openboolean-Controlled dialog state; supports v-model:open.
defaultOpenbooleanfalseInitial dialog state for uncontrolled use.
valuestring-Controlled search query; supports v-model:value.
defaultValuestring""Initial uncontrolled search query.
itemsT[][]Flat results or top-level groups rendered by Results.
getSelectableItems(items: T[]) => T[]identityFlattens grouped data into keyboard-selectable items.
itemToStringValue(item: T) => stringtitle / label / name / valueMaps an object item to searchable text.
itemToValue(item: T) => stringid / value / title / labelMaps an item to its stable Ark UI value.
isItemDisabled(item: T) => booleanitem.disabledMarks commands unavailable and skips them during navigation.
filterfalse | (item, query) => booleancontains textFilters flat items. Use false for externally filtered groups.
ariaLabelstring"Command palette"Accessible name for the dialog.
modalbooleantrueControls focus trapping and outside interaction.

ResultItem

PropTypeDefaultDescription
valueT-Item passed to selection events.
titlestring-Primary result label.
breadcrumbsstring[][]Hierarchy rendered before the title.
descriptionstring-Supporting result text.
titleHighlights[number, number][][]Inclusive title match ranges.
breadcrumbHighlights[number, number][][][]Inclusive ranges for each breadcrumb.
showArrowbooleantrueShows the destination arrow.
externalbooleanfalseShows an external-destination cue.
nonInteractivebooleanfalseRenders rich result content without activation.

Events

EventPayloadDescription
update:openbooleanUpdates controlled dialog state.
openChangebooleanReports each dialog state change.
update:valuestringUpdates the controlled search query.
valueChangestringReports each search query change.
selectitem, { newTab, event? }Reports activation and modified-key intent.
itemHighlighteditem, { index, reason }Reports keyboard, pointer, and reset highlights.
closevoidReports closure after dismissal or a state change.