{"slug":"command-palette","category":"navigation","job":"ask","added":"2026-09-12T22:10Z","title":"Command Palette","description":"A searchable ⌘K overlay for jumping to sessions or running quick actions.","url":"https://ai-patterns.app/patterns/navigation/command-palette","markdown":"https://ai-patterns.app/patterns/navigation/command-palette.md","json":"https://ai-patterns.app/api/patterns/navigation/command-palette","seenIn":[{"team":"Linear","source":"https://linear.app/enablement/guide/navigating-linear"}],"spec":"# Command Palette\n\n## Summary\nA search-first overlay for jumping to or launching something — a session, a document, a command — without leaving the keyboard. A single input filters a short list of quick actions and recent items; arrow keys move the highlight, Enter commits it.\n\n## When to use\n- Switching between many similar items (sessions, chats, files, projects) faster than a sidebar or list view allows.\n- Surfacing a small set of global actions (\"New session\", \"New chat\") alongside recent history in one place.\n- Any surface that already has a keyboard-shortcut culture (⌘K launchers, IDE-style command bars).\n\n## When not to use\n- A handful of items (fewer than ~6) that fit comfortably in a visible list or dropdown — the overlay adds a step for no benefit.\n- Destructive or multi-step actions (delete, bulk export). The palette commits on a single Enter; anything needing confirmation belongs in its own flow.\n- As the only way to reach a primary action. Always keep a visible trigger (button, menu item) alongside the keyboard shortcut — the shortcut is an accelerator, not the sole path.\n\n## Anatomy\n- Backdrop (dims and blurs the page behind the palette, closes on click).\n- Search input with a leading search icon and a close (×) button.\n- Scrollable result list, grouped into labeled sections (e.g. \"Quick actions\", \"Recent\").\n- Each row: optional leading icon, label (truncates), optional trailing meta text (timestamp, source).\n- Footer hint bar: keyboard legend for select / open / close.\n\n## Behavior\n- Opens via an explicit trigger (button) and/or a global shortcut (⌘K / Ctrl+K); the trigger must remain visible even where the shortcut exists.\n- The search input autofocuses the moment the palette opens.\n- With an empty query, show the full grouped list (quick actions first, then recent items) — don't force typing before anything is visible.\n- Typing filters items by label — and by optional hidden keywords such as a description — across all groups in place; label matches rank above keyword-only matches, and groups with no matches collapse out entirely rather than showing an empty header.\n- Arrow Up/Down move the highlighted row, clamped at the first/last item (no wraparound) so repeated key-holds don't overshoot silently.\n- Hovering a row also updates the highlight, kept in sync with keyboard navigation.\n- Enter commits the highlighted row; clicking a row commits it directly.\n- Escape, a backdrop click, or the × button close the palette without committing.\n- Closing resets the query and highlight so the next open starts fresh.\n- The highlighted row auto-scrolls into view as it changes, so keyboard navigation never drifts off-screen in a long list.\n\n## Content guidelines\n- Row labels are the item's real name (a session title, a file name) — never a truncated ID or slug.\n- Meta text is short and secondary: a relative timestamp (\"Just now\", \"Last hour\") or a compact source tag (\"PR #52\"). Never wrap it.\n- Group labels are short nouns (\"Quick actions\", \"Recent\") — not instructions.\n- The empty-results message is a plain statement (\"No matches\"), not a call to action.\n\n## Accessibility\n- Root overlay uses `role=\"dialog\"` with `aria-modal=\"true\"` and a descriptive `aria-label`.\n- The input uses `role=\"combobox\"` with `aria-expanded` and `aria-controls` pointing at the result list, plus `aria-activedescendant` tracking the highlighted row's id.\n- The result list uses `role=\"listbox\"`; each row is `role=\"option\"` with `aria-selected` reflecting the current highlight.\n- All interaction must work from the keyboard alone: focus starts in the input, arrow keys and Enter never require a pointer.\n- Respect `prefers-reduced-motion` by skipping the backdrop fade and panel scale/slide.\n\n## Related patterns\n- **Quick Chat Dock** — Use it instead when people should be able to ask the AI from anywhere, not just jump to commands.\n- **Instruction Tool Insert** — Use it instead when the picked item is inserted into written instructions rather than run or opened.\n- **Describe Filters** — Use it instead when people want to narrow a set by intent rather than jump to a known item.\n- **Natural Language Query** — Use it instead when people are asking a question of data rather than looking for a command.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { CornerDownLeft, Search, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface CommandPaletteItem {\n  id: string;\n  label: string;\n  icon?: React.ComponentType<{ className?: string }>;\n  meta?: string;\n  /** Extra searchable text (e.g. a description) — matched by the filter but not rendered. */\n  keywords?: string;\n}\n\nexport interface CommandPaletteGroup {\n  label: string;\n  items: CommandPaletteItem[];\n}\n\nexport interface CommandPaletteProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  groups: CommandPaletteGroup[];\n  placeholder?: string;\n  emptyLabel?: string;\n  onSelect?: (item: CommandPaletteItem) => void;\n  className?: string;\n}\n\nexport function CommandPalette({\n  open,\n  onOpenChange,\n  groups,\n  placeholder,\n  emptyLabel,\n  onSelect,\n  className,\n}: CommandPaletteProps) {\n  // Portal to <body>: an ancestor with backdrop-filter, filter or transform\n  // (e.g. a blurred sticky header) would otherwise become the containing\n  // block for this `fixed` overlay and clip it to that ancestor's box.\n  const mounted = React.useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false\n  );\n\n  function close() {\n    onOpenChange(false);\n  }\n\n  if (!mounted) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {open && (\n        <div className={cn(\"fixed inset-0 z-50 flex justify-center px-4 pt-[12vh]\", className)}>\n          <motion.div\n            className=\"absolute inset-0 bg-black/40 backdrop-blur-sm\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.15 }}\n            onClick={close}\n            aria-hidden\n          />\n\n          <motion.div\n            initial={{ opacity: 0, y: -8, scale: 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: -8, scale: 0.98 }}\n            transition={{ duration: 0.15, ease: \"easeOut\" }}\n            className=\"relative z-10 w-full max-w-lg\"\n          >\n            <CommandPaletteWindow\n              groups={groups}\n              placeholder={placeholder}\n              emptyLabel={emptyLabel}\n              autoFocus\n              onSelect={(item) => {\n                onSelect?.(item);\n                close();\n              }}\n              onClose={close}\n            />\n          </motion.div>\n        </div>\n      )}\n    </AnimatePresence>,\n    document.body\n  );\n}\n\nfunction subscribeNoop() {\n  return () => {};\n}\n\nexport interface CommandPaletteWindowProps {\n  groups: CommandPaletteGroup[];\n  placeholder?: string;\n  emptyLabel?: string;\n  onSelect?: (item: CommandPaletteItem) => void;\n  onClose?: () => void;\n  autoFocus?: boolean;\n  className?: string;\n}\n\n/** The palette's search input, result list, and key-hint footer, without the modal chrome (backdrop, fixed positioning). Used by `CommandPalette` for the real overlay, and reusable on its own wherever a static, non-modal preview of the window is useful. */\nexport function CommandPaletteWindow({\n  groups,\n  placeholder = \"Search or run a command...\",\n  emptyLabel = \"No matches\",\n  onSelect,\n  onClose,\n  autoFocus = false,\n  className,\n}: CommandPaletteWindowProps) {\n  const listId = React.useId();\n  const [query, setQuery] = React.useState(\"\");\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n\n  const visibleGroups = React.useMemo(() => filterGroups(groups, query), [groups, query]);\n  const flatItems = React.useMemo(() => visibleGroups.flatMap((g) => g.items), [visibleGroups]);\n\n  React.useEffect(() => {\n    if (autoFocus) {\n      const id = requestAnimationFrame(() => inputRef.current?.focus());\n      return () => cancelAnimationFrame(id);\n    }\n  }, [autoFocus]);\n\n  const prevActiveIndexRef = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    const prevActiveIndex = prevActiveIndexRef.current;\n    prevActiveIndexRef.current = activeIndex;\n    if (prevActiveIndex === null || prevActiveIndex === activeIndex) return;\n\n    const row = listRef.current?.querySelector<HTMLElement>(`[data-index=\"${activeIndex}\"]`);\n    row?.scrollIntoView({ block: \"nearest\" });\n  }, [activeIndex]);\n\n  function commit(item: CommandPaletteItem | undefined) {\n    if (!item) return;\n    onSelect?.(item);\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      onClose?.();\n    } else if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActiveIndex((i) => Math.max(i - 1, 0));\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      commit(flatItems[activeIndex]);\n    }\n  }\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label=\"Command palette\"\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"flex h-fit max-h-[70vh] w-full flex-col overflow-hidden rounded-2xl border bg-popover shadow-2xl\",\n        className\n      )}\n    >\n      <div className=\"flex items-center gap-2.5 border-b px-4 py-3.5\">\n        <Search className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden />\n        <input\n          ref={inputRef}\n          role=\"combobox\"\n          aria-expanded=\"true\"\n          aria-controls={listId}\n          aria-activedescendant={flatItems[activeIndex] ? `${listId}-item-${flatItems[activeIndex].id}` : undefined}\n          value={query}\n          onChange={(e) => {\n            setQuery(e.target.value);\n            setActiveIndex(0);\n          }}\n          placeholder={placeholder}\n          className=\"flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n        />\n        {onClose && (\n          <button\n            type=\"button\"\n            onClick={onClose}\n            aria-label=\"Close\"\n            className=\"shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            <X className=\"size-4\" />\n          </button>\n        )}\n      </div>\n\n      <div id={listId} role=\"listbox\" ref={listRef} className=\"min-h-0 flex-1 overflow-y-auto p-2\">\n        {flatItems.length === 0 ? (\n          <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">{emptyLabel}</p>\n        ) : (\n          visibleGroups.map((group) => (\n            <div key={group.label} className=\"mb-2 last:mb-0\">\n              <p className=\"px-3 py-1.5 text-xs font-medium text-muted-foreground\">{group.label}</p>\n              {group.items.map((item) => {\n                const index = flatItems.indexOf(item);\n                const active = index === activeIndex;\n                const Icon = item.icon;\n                return (\n                  <button\n                    key={item.id}\n                    id={`${listId}-item-${item.id}`}\n                    data-index={index}\n                    role=\"option\"\n                    aria-selected={active}\n                    type=\"button\"\n                    onMouseEnter={() => setActiveIndex(index)}\n                    onClick={() => commit(item)}\n                    className={cn(\n                      \"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors\",\n                      active ? \"bg-accent text-accent-foreground\" : \"text-foreground\"\n                    )}\n                  >\n                    {Icon && <Icon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden />}\n                    <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                    {item.meta && <span className=\"shrink-0 text-xs text-muted-foreground\">{item.meta}</span>}\n                  </button>\n                );\n              })}\n            </div>\n          ))\n        )}\n      </div>\n\n      <div className=\"flex items-center gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground\">\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd>↑</Kbd>\n          <Kbd>↓</Kbd>\n          Select\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <Kbd>\n            <CornerDownLeft className=\"size-3\" />\n          </Kbd>\n          Open\n        </span>\n        <span className=\"ml-auto flex items-center gap-1.5\">\n          <Kbd>Esc</Kbd>\n          Close\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return (\n    <kbd className=\"flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[10px] font-medium text-muted-foreground\">\n      {children}\n    </kbd>\n  );\n}\n\nfunction filterGroups(groups: CommandPaletteGroup[], query: string): CommandPaletteGroup[] {\n  const trimmed = query.trim().toLowerCase();\n  if (!trimmed) return groups;\n\n  return groups\n    .map((group) => ({\n      ...group,\n      // Label matches rank above matches found only in keywords.\n      items: [\n        ...group.items.filter((item) => item.label.toLowerCase().includes(trimmed)),\n        ...group.items.filter(\n          (item) =>\n            !item.label.toLowerCase().includes(trimmed) &&\n            !!item.keywords?.toLowerCase().includes(trimmed)\n        ),\n      ],\n    }))\n    .filter((group) => group.items.length > 0);\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}