Skip to contentVibraUI
Navigation & layout

Command palette

The ⌘K dialog: grouped commands, fuzzy search, and the shortcut for each one.

A client component built on the upstream CommandDialog, which owns the focus trap, the listbox semantics, and the dialog title. The hotkey is a window keydown listener in an effect, matched on metaKey || ctrlKey so "mod" is ⌘ on mac and Ctrl elsewhere; it reads the current open state through a ref, so an inline onOpenChange never resubscribes it. Leave open and onOpenChange off to let the palette manage itself, or pass both to drive it from your own state. Each item's keywords are folded into its cmdk value, so a command is findable by words that never appear on screen. An id listed in recentIds is moved into a "Recent" group at the top rather than copied there — cmdk keys a row by its value, and the same value twice would highlight both rows at once — and a group left empty by the move is dropped. A shortcut may be a chord ("mod+k") or a sequence of them ("g d"), which renders as one keycap per chord. The matcher itself is the shared hotkeys lib, which is also what binds Compare to c on a chart toolbar: a held chord is ignored while it repeats, so leaning on the keys does not strobe the dialog, and an unmodified key never fires while the reader is typing into a field. className and any other props land on the command root inside the dialog, which is the element carrying data-slot="command-palette"; the dialog shell keeps its own sizing. The upstream dialog keeps its visually hidden title in the page even while closed, so a page that mounts a palette always carries that one heading.

Install

npx shadcn@latest add @vibra/command-palette

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
groups{ heading: string; items: CommandItem[] }[]—The commands, in labelled groups, in the order they should appear.
openboolean—Drives the dialog from your own state; leave it off and the palette owns it.
onOpenChange(open: boolean) => void—Called by the hotkey, by a selection, and by every upstream dismissal.
placeholderstringType a command or search…Placeholder for the search field.
hotkeystring | nullmod+kThe shortcut that opens and closes it from anywhere; null turns it off.
emptyMessagestringNo results found.Shown when the search matches nothing.
titlestringCommand paletteWhat the dialog is called for a screen reader — "Search" for a palette that only finds things.
descriptionstringSearch for a command to run.One line under the name, read out with it.
recentIdsstring[]—Ids lifted into a "Recent" group at the top, in the order given.
footerReact.ReactNode—A hint row under the list, usually the keys that run and dismiss.
classNamestring—Classes for the command root inside the dialog; the rest of the props go there too.
CommandItem.idstring—Unique across every group.
CommandItem.labelstring—The command itself, and the first thing the search matches.
CommandItem.descriptionstring—A quiet second line under the label.
CommandItem.iconReact.ReactNode—Sits before the label; sized to 4 unless it sets its own size.
CommandItem.shortcutstring—A chord ("mod+n") or a sequence of them ("g d"), rendered as keycaps.
CommandItem.keywordsstring[]—Extra words the command is findable by; they never show.
CommandItem.onSelect() => void—Runs the command; the palette closes straight after.

Dependencies

Source

components/ui/command-palette.tsx
"use client"

import * as React from "react"

import { matchesHotkey } from "@/lib/hotkeys"
import {
  Command,
  CommandDialog,
  CommandEmpty,
  // The palette's own CommandGroup and CommandItem are the data shapes callers
  // hand in; the primitive uses those two names for components, so the
  // components are the ones that get renamed here.
  CommandGroup as CommandGroupList,
  CommandInput,
  CommandItem as CommandRow,
  CommandList,
  CommandShortcut,
  useCommandState,
} from "@/components/ui/command"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"

export type CommandItem = {
  id: string
  label: string
  /** Sits before the label; sized to 4 unless it sets its own size. */
  icon?: React.ReactNode
  /** A chord ("mod+k") or a sequence of them ("g d"), rendered as keycaps. */
  shortcut?: string
  /** Extra words the item should be findable by; they never show. */
  keywords?: string[]
  onSelect: () => void
  /** A quiet second line under the label. */
  description?: string
}

export type CommandGroup = {
  heading: string
  items: CommandItem[]
}

export type CommandPaletteProps = React.ComponentProps<typeof Command> & {
  open?: boolean
  onOpenChange?: (open: boolean) => void
  groups: CommandGroup[]
  placeholder?: string
  /** The shortcut that opens and closes it from anywhere; null turns it off. */
  hotkey?: string | null
  emptyMessage?: string
  /** Ids to lift into a "Recent" group at the top, in the order given. */
  recentIds?: string[]
  footer?: React.ReactNode
  /** What the dialog is called for a screen reader — "Search" for a palette that only finds things. */
  title?: string
  /** One line under the name, read out with it. */
  description?: string
}

/** cmdk matches against the item's value, so the keywords are folded into it. */
function itemValue(item: CommandItem): string {
  return [item.label, ...(item.keywords ?? [])].join(" ")
}

/**
 * What the query left, said aloud: "12 results", "1 result", or the empty
 * message. The rows repaint under a sighted reader as they type; a screen
 * reader hears nothing of that without a live region, and cmdk's own empty
 * state is `role="presentation"`. Inside the command root, because the count
 * is read from cmdk's store.
 */
function CommandResultCount({ emptyMessage }: { emptyMessage: string }) {
  const count = useCommandState((state) => state.filtered.count)
  return (
    <div data-slot="command-palette-status" role="status" aria-live="polite" className="sr-only">
      {count === 0 ? emptyMessage : `${count} ${count === 1 ? "result" : "results"}`}
    </div>
  )
}

/** The ⌘K dialog: grouped commands, fuzzy search, and the shortcut for each one. */
function CommandPalette({
  open,
  onOpenChange,
  groups,
  placeholder = "Type a command or search…",
  hotkey = "mod+k",
  emptyMessage = "No results found.",
  recentIds,
  footer,
  title = "Command palette",
  description = "Search for a command to run.",
  className,
  ...props
}: CommandPaletteProps) {
  const [selfOpen, setSelfOpen] = React.useState(false)
  const isOpen = open ?? selfOpen

  const setOpen = React.useCallback(
    (next: boolean) => {
      // Uncontrolled palettes open themselves; controlled ones only report.
      if (open === undefined) setSelfOpen(next)
      onOpenChange?.(next)
    },
    [open, onOpenChange]
  )

  // The listener is attached once per hotkey and reads the current state
  // through a ref, so an inline onOpenChange does not resubscribe it every
  // render.
  const latest = React.useRef({ isOpen, setOpen })
  React.useEffect(() => {
    latest.current = { isOpen, setOpen }
  }, [isOpen, setOpen])

  React.useEffect(() => {
    if (!hotkey) return
    const handleKeyDown = (event: KeyboardEvent) => {
      if (!matchesHotkey(event, hotkey)) return
      event.preventDefault()
      latest.current.setOpen(!latest.current.isOpen)
    }
    window.addEventListener("keydown", handleKeyDown)
    return () => window.removeEventListener("keydown", handleKeyDown)
  }, [hotkey])

  const visibleGroups = React.useMemo(() => {
    const recent = new Set(recentIds ?? [])
    if (recent.size === 0) return groups

    const byId = new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item]))
    const recentItems = (recentIds ?? [])
      .map((id) => byId.get(id))
      .filter((item): item is CommandItem => item !== undefined)
    if (recentItems.length === 0) return groups

    // Promoted, not copied: cmdk keys a row by its value, so the same item in
    // two groups would highlight in both at once. An emptied group goes too.
    const rest = groups
      .map((group) => ({
        ...group,
        items: group.items.filter((item) => !recent.has(item.id)),
      }))
      .filter((group) => group.items.length > 0)

    return [{ heading: "Recent", items: recentItems }, ...rest]
  }, [groups, recentIds])

  return (
    <CommandDialog
      open={isOpen}
      onOpenChange={setOpen}
      title={title}
      description={description}
      className="gap-0 sm:max-w-xl"
    >
      {/* The command root is the palette's own root: data-slot lands here,
          replacing the primitive's name (nothing in command.tsx selects on it),
          and so do className and the rest of the caller's props. The dialog
          shell above keeps its own sizing. */}
      {/* `label` is what cmdk names the combobox by: it renders the text as
          the hidden <label> the input is already aria-labelledby, which was
          empty — an unnamed edit combobox — without it. */}
      <Command data-slot="command-palette" label={title} loop className={className} {...props}>
        <CommandInput placeholder={placeholder} />
        <CommandResultCount emptyMessage={emptyMessage} />
        <CommandList>
          <CommandEmpty>{emptyMessage}</CommandEmpty>
          {visibleGroups.map((group) => (
            <CommandGroupList key={group.heading} heading={group.heading}>
              {group.items.map((item) => (
                <CommandRow
                  key={item.id}
                  value={itemValue(item)}
                  onSelect={() => {
                    item.onSelect()
                    setOpen(false)
                  }}
                >
                  {item.icon}
                  <span className="flex min-w-0 flex-col">
                    <span className="truncate">{item.label}</span>
                    {item.description ? (
                      <span className="truncate text-xs text-muted-foreground">
                        {item.description}
                      </span>
                    ) : null}
                  </span>
                  {item.shortcut ? (
                    // COUPLED TO registry/vibra/ui/command.tsx: CommandShortcut
                    // spaces plain-text shortcuts out with tracking-widest,
                    // which pulls the keycaps apart — but the slot has to stay,
                    // because CommandItem hides its trailing tick only for a
                    // row that carries a data-slot="command-shortcut".
                    <CommandShortcut className="flex shrink-0 items-center gap-1 tracking-normal">
                      {item.shortcut
                        .split(" ")
                        .filter(Boolean)
                        .map((chord, index) => (
                          <KbdShortcut key={index} keys={chord} size="sm" />
                        ))}
                    </CommandShortcut>
                  ) : null}
                </CommandRow>
              ))}
            </CommandGroupList>
          ))}
        </CommandList>
      </Command>

      {footer ? (
        <div
          data-slot="command-palette-footer"
          className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-muted-foreground"
        >
          {footer}
        </div>
      ) : null}
    </CommandDialog>
  )
}

export { CommandPalette }