Command Palette

A keyboard-first command palette built around a flat items array. ⌘K (Mac) / Ctrl+K (everywhere else) toggles a focus-trapped dialog with a search input, a fuzzy-filtered list, and arrow / Enter / Esc navigation. Owns its own scorer — no cmdk dependency — so the bundle stays small.

If you want a compound, slot-driven API instead, reach for Command Menu.

Customize
State
Content
0

Installation

npx shadcn@latest add https://craftbits.dev/r/command-palette.json

Usage

Pass an items array — each row is rendered into the list, fuzzy-matched against the query, and dispatches onSelect on click or Enter.

"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CommandPalette } from "@craft-bits/core";
 
export function SiteSearch() {
  const router = useRouter();
  const [open, setOpen] = useState(false);
 
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Open <kbd>⌘K</kbd>
      </button>
 
      <CommandPalette
        open={open}
        onOpenChange={setOpen}
        hotkey
        items={[
          { id: "home", group: "Pages", label: "Home", hint: "/", onSelect: () => router.push("/") },
          { id: "docs", group: "Pages", label: "Docs", hint: "/docs", onSelect: () => router.push("/docs") },
          { id: "theme", group: "Actions", label: "Toggle theme", hint: "⌘J", onSelect: toggleTheme },
        ]}
      />
    </>
  );
}

Uncontrolled — let the palette own its state, useful when the global hotkey is the only trigger:

<CommandPalette items={items} defaultOpen={false} hotkey />

Controlled query — pair query + onQueryChange to drive remote search or analytics:

<CommandPalette
  items={results}
  query={query}
  onQueryChange={setQuery}
  open={open}
  onOpenChange={setOpen}
/>

Understanding the component

  1. Flat data, no slots. A single items array — each row carries id, label, optional hint, optional icon, optional keywords, optional group, and onSelect. Reaching for the data shape rather than compound JSX keeps consumers terse when rows come from a fetch.
  2. Built-in fuzzy filter. A 5-tier scorer (exact, prefix, substring, keyword substring, subsequence) ranks rows live as the user types. No cmdk dependency — the scorer is ~30 lines so the bundle stays lean.
  3. Grouping preserves first-seen order. Items sharing a group cluster under one eyebrow heading. Group order follows the order each heading first appears in items, so consumers control the layout without a separate groups prop.
  4. Controlled + uncontrolled. Both open and query accept the Radix value/defaultValue pair. Pass defaultOpen / defaultQuery for self-managed state, or wire both for external control.
  5. Global hotkey (opt-in). Pass hotkey to register a window keydown listener that toggles the palette on ⌘K (Mac) / Ctrl+K (other). The handler preventDefaults so the browser's Find-in-Page never fights you. Defaults to off — the assumption is the consumer wires the trigger themselves.
  6. Keyboard navigation. Up / Down move between enabled rows, Home / End jump to the ends, Enter selects, Esc closes. Disabled rows are skipped. Mouse-hover sets the active row too, so keyboard and pointer share one source of truth.
  7. Focus-trap + restore. On open, focus moves into the input on the next frame so motion has time to mount the panel. Tab cycles inside the panel without escaping. On close, focus restores to whatever element opened the palette.
  8. Motion. Backdrop fades in; panel scale-pops from the top — both with SPRINGS.snap. AnimatePresence initial={false} skips the first-render animation so apps that mount with defaultOpen don't get a flash.

Variants

Uncontrolled + global hotkey

<CommandPalette items={items} defaultOpen={false} hotkey />

Without the hotkey hint footer

<CommandPalette
  items={items}
  open={open}
  onOpenChange={setOpen}
  showHotkeyHint={false}
/>

Grouped items

<CommandPalette
  items={[
    { id: "home", group: "Pages", label: "Home", hint: "/", onSelect: goHome },
    { id: "docs", group: "Pages", label: "Docs", hint: "/docs", onSelect: goDocs },
    { id: "new", group: "Actions", label: "New file", hint: "⌘N", onSelect: createFile },
  ]}
  open={open}
  onOpenChange={setOpen}
/>

Props

CommandPalette

PropTypeDefaultDescription
itemsCommandPaletteItem[]Items to surface. Filtered live as the user types.
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFired when the palette opens or closes.
querystringControlled search query. Pair with onQueryChange.
defaultQuerystring""Uncontrolled initial query.
onQueryChange(query: string) => voidFired when the user types in the search input.
placeholderstring"Search…"Input placeholder.
labelstring"Command palette"Accessible name for the dialog.
emptyMessageReactNode"No results found."Body for the no-match row.
hotkeybooleanfalseRegister the global ⌘K / Ctrl+K toggle.
showHotkeyHintbooleantrueRender the ⌘ + K hint row at the panel footer.
classNamestringMerged onto the dialog panel.
overlayClassNamestringMerged onto the backdrop overlay.

CommandPaletteItem

PropTypeDefaultDescription
idstringStable identifier — used as the React key and selection token.
labelstringVisible label, also the primary fuzzy-match target.
hintReactNodeRight-aligned subtitle (path, kbd, description).
iconReactNodeLeft-aligned glyph.
keywordsreadonly string[]Extra keywords mixed into the fuzzy score.
groupstringHeading — items sharing a group cluster under one eyebrow.
disabledbooleanfalseDim and skip the row in keyboard navigation.
onSelect() => voidFires on click or Enter when this is the active row.

Accessibility

  • The dialog panel carries role="dialog" + aria-modal="true" + an aria-label (defaults to "Command palette"; override with label). A hidden labelled span backs aria-labelledby so screen readers always announce the dialog name.
  • The search input uses the WAI-ARIA combobox pattern: role="combobox", aria-controls points at the list, aria-activedescendant tracks the active row's id, aria-autocomplete="list" advertises the inline-completion model.
  • The list uses role="listbox"; each row is role="option" with aria-selected toggled on the active row and aria-disabled on disabled rows.
  • Keyboard: Up / Down move between enabled rows, Home / End jump to the ends, Enter selects, Esc closes. Tab cycles focus inside the panel without escaping. The global ⌘K / Ctrl+K toggle is registered on window and removed on unmount.
  • On open, focus moves into the input on the next frame so motion has time to mount the panel. On close, focus restores to whatever element opened the palette.
  • Body scroll is locked while the palette is open so the page underneath can't scroll out from under the dialog.

Credits

  • Extracted from: terminal-dreams (src/components/CommandPalette/CommandPalette.tsx). The original wrapped Radix Dialog + cmdk and hard-coded the blog's Pages / Principles / Frontend Design / Posts groups; craft-bits drops the project-specific data, removes the cmdk dependency by inlining a small fuzzy scorer, and generalises the API to a flat items array with optional group, hint, icon, and keywords per row.
  • Inspired by: cmdk — the reference command-palette implementation.