{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "title": "Command Palette",
  "description": "An animated command palette for React and shadcn, opened with ⌘K, where Enter runs the first match.",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-overlay-core.json",
    "https://eased.dev/r/eased-scroll-fade.json",
    "https://eased.dev/r/kbd.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/command-palette/command-palette.tsx",
      "content": "\"use client\";\n\nimport { Autocomplete } from \"@base-ui/react/autocomplete\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport {\n  backdropClass,\n  groupClass,\n  groupLabelClass,\n  itemClass,\n  ItemParts,\n  modalMotion,\n  modalSurface,\n  separatorClass,\n} from \"../_primitives/overlay-core/overlay-core\";\nimport { scrollFadeMask, useScrollEdges } from \"../_primitives/scroll-fade/scroll-fade\";\nimport { Kbd } from \"../kbd/kbd\";\nimport { isEditable } from \"../lib/dom\";\nimport { modKey, useIsMac } from \"../lib/platform\";\n\n/**\n * A command palette on Base UI's Dialog and Autocomplete: a filtering list of commands that\n * opens ~15vh from the top over the blurred scrim. Opened from its trigger it rises 8px at\n * 0.96 on the smooth spring; opened from ⌘K it appears at once (rule 84). Either way it leaves\n * 4px in 160ms. Enter runs the first match, Escape closes, focus goes back where it was.\n */\n\n/* ---------------------------------------------------------------- Types ---------- */\n\nexport type CommandItem = {\n  /** Unique within the palette. */\n  id: string;\n  /** What the command says. Matched against the query, with matching characters emphasised. */\n  label: string;\n  /** 16px icon (or a 20px avatar) in the leading track. */\n  icon?: ReactNode;\n  /** Muted text in the trailing column: what kind of thing this is, or where it goes. Also matched. */\n  description?: ReactNode;\n  /** Key caps shown in the trailing column, e.g. `[\"C\"]` or `[\"G\", \"I\"]`. Shown only; bind them yourself. */\n  shortcut?: string[];\n  /** Extra words that should find this command (\"theme\", \"appearance\"). Not shown. */\n  keywords?: string[];\n  /** Runs when the command is chosen (click, tap or Enter). The palette closes right after. */\n  onSelect: () => void;\n  /** Shown but cannot be highlighted or run. */\n  disabled?: boolean;\n};\n\nexport type CommandGroup = {\n  /** The group's heading. An empty label renders no heading; the group is set off by a rule instead. */\n  label: string;\n  items: CommandItem[];\n};\n\nexport type CommandPaletteProps = {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled starting state. Default false. */\n  defaultOpen?: boolean;\n  /** Called whenever the palette opens or closes, from any source (trigger, hotkey, Esc, a command). */\n  onOpenChange?: (open: boolean) => void;\n  /** Commands in labelled groups. Groups with no matches hide while filtering. */\n  groups?: CommandGroup[];\n  /** A flat list of commands, for palettes with no groups. Ignored when `groups` is set. */\n  items?: CommandItem[];\n  /** The input's placeholder. Default \"Search or jump to…\". */\n  placeholder?: string;\n  /** The input's accessible name. Default \"Search commands\". */\n  inputLabel?: string;\n  /** The dialog's accessible name. Default \"Command palette\". */\n  label?: string;\n  /**\n   * The shortcut that toggles the palette, e.g. `\"mod+k\"` (default), `\"mod+shift+p\"` or `\"/\"`.\n   * `mod` is ⌘ on Apple platforms and Ctrl elsewhere. Shortcuts with a modifier work from inside\n   * any text field; bare keys are ignored while someone is typing. One palette per page owns a\n   * given hotkey (the first mounted); pass false to opt out, e.g. while a demo is off screen.\n   */\n  hotkey?: string | false;\n  /** Shown when nothing matches. Default \"No results\". The query is passed for \"No results for “…”\". */\n  emptyText?: ReactNode | ((query: string) => ReactNode);\n  /** The bar under the list. Default: ↑↓ Navigate · ↵ Open · Esc Close. False hides it. */\n  footer?: ReactNode | false;\n  /** Arrow keys wrap from the last command back through the input to the first. Default true. */\n  loop?: boolean;\n  /** Portal target. Default `document.body`. */\n  container?: HTMLElement | null;\n  /** Appended to the popup's classes. */\n  className?: string;\n  /** Triggers, usually a `CommandPaletteTrigger`. They open the palette and get focus back on close. */\n  children?: ReactNode;\n};\n\n/* --------------------------------------------------------------- Hotkey ---------- */\n\ntype Hotkey = { key: string; mod: boolean; ctrl: boolean; meta: boolean; shift: boolean; alt: boolean };\n\n/** \"mod+shift+p\" → its parts. The last segment is the key; \"+\" itself is spelled \"plus\". */\nexport function parseHotkey(hotkey: string): Hotkey {\n  const parts = hotkey.toLowerCase().split(\"+\").map((p) => p.trim());\n  const key = parts.pop() ?? \"\";\n  const has = (...names: string[]) => parts.some((p) => names.includes(p));\n  return {\n    key: key === \"plus\" ? \"+\" : key === \"space\" ? \" \" : key,\n    mod: has(\"mod\"),\n    ctrl: has(\"ctrl\", \"control\"),\n    meta: has(\"meta\", \"cmd\", \"command\"),\n    shift: has(\"shift\"),\n    alt: has(\"alt\", \"option\", \"opt\"),\n  };\n}\n\nfunction matchesHotkey(e: KeyboardEvent, h: Hotkey, mac: boolean) {\n  const meta = h.meta || (h.mod && mac);\n  const ctrl = h.ctrl || (h.mod && !mac);\n  if (e.metaKey !== meta || e.ctrlKey !== ctrl || e.altKey !== h.alt) return false;\n  // Shift is only checked for letters and named keys: \"?\" already implies it.\n  if (h.key.length > 1 || /[a-z]/.test(h.key)) {\n    if (e.shiftKey !== h.shift) return false;\n  }\n  // `code` keeps letter shortcuts working on other layouts and with ⌥, which changes `key`.\n  return e.key.toLowerCase() === h.key || (/^[a-z]$/.test(h.key) && e.code === `Key${h.key.toUpperCase()}`);\n}\n\n/** \"mod+k\" → \"Meta+K\" for `aria-keyshortcuts`. */\nfunction ariaHotkey(h: Hotkey, mac: boolean) {\n  const mods = [h.ctrl || (h.mod && !mac) ? \"Control\" : \"\", h.meta || (h.mod && mac) ? \"Meta\" : \"\", h.alt ? \"Alt\" : \"\", h.shift ? \"Shift\" : \"\"];\n  return [...mods.filter(Boolean), h.key === \" \" ? \"Space\" : h.key.toUpperCase()].join(\"+\");\n}\n\n/** \"mod+k\" → [\"⌘\", \"K\"] (or [\"Ctrl\", \"K\"]) for key caps. */\nexport function hotkeyCaps(hotkey: string, mac: boolean) {\n  const h = parseHotkey(hotkey);\n  const caps: string[] = [];\n  if (h.ctrl || (h.mod && !mac)) caps.push(mac ? \"⌃\" : \"Ctrl\");\n  if (h.alt) caps.push(mac ? \"⌥\" : \"Alt\");\n  if (h.shift) caps.push(\"⇧\");\n  if (h.meta || (h.mod && mac)) caps.push(modKey(true));\n  caps.push(h.key === \" \" ? \"Space\" : h.key.toUpperCase());\n  return caps;\n}\n\n// One owner per hotkey on the page: the first palette to mount keeps it; later ones stay quiet.\nconst owners = new Map<string, string[]>();\nconst ownerListeners = new Set<() => void>();\nconst subscribeOwners = (l: () => void) => {\n  ownerListeners.add(l);\n  return () => ownerListeners.delete(l);\n};\n\nfunction useHotkeyOwner(hotkey: string | false) {\n  const id = useId();\n  const key = hotkey ? hotkey.toLowerCase().replace(/\\s+/g, \"\") : \"\";\n  useEffect(() => {\n    if (!key) return;\n    owners.set(key, [...(owners.get(key) ?? []), id]);\n    ownerListeners.forEach((l) => l());\n    return () => {\n      const rest = (owners.get(key) ?? []).filter((x) => x !== id);\n      if (rest.length) owners.set(key, rest);\n      else owners.delete(key);\n      ownerListeners.forEach((l) => l());\n    };\n  }, [key, id]);\n  return useSyncExternalStore(\n    subscribeOwners,\n    () => !!key && (owners.get(key)?.[0] ?? id) === id,\n    () => false,\n  );\n}\n\n/* ---------------------------------------------------------------- State ---------- */\n\n/**\n * Open state for a palette you want to open from elsewhere (a menu item, a button far from the\n * trigger): spread `props` onto `CommandPalette` and call `show()`, `hide()` or `toggle()`.\n */\nexport function useCommandPalette(defaultOpen = false) {\n  const [open, setOpen] = useState(defaultOpen);\n  const show = useCallback(() => setOpen(true), []);\n  const hide = useCallback(() => setOpen(false), []);\n  const toggle = useCallback(() => setOpen((o) => !o), []);\n  return { open, setOpen, show, hide, toggle, props: { open, onOpenChange: setOpen } };\n}\n\n/* ------------------------------------------------------------- Palette ---------- */\n\n/**\n * The palette: trigger(s) as children, commands as `groups` or `items`. Opens 15vh from the\n * top (8px from it under 640px), 576px wide, never wider than the screen less 32px. The list\n * scrolls within min(60dvh, 22rem) with feathered edges; the first match is always\n * highlighted, so Enter runs it.\n */\nexport function CommandPalette({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  groups: groupsProp,\n  items,\n  placeholder = \"Search or jump to…\",\n  inputLabel = \"Search commands\",\n  label = \"Command palette\",\n  hotkey = \"mod+k\",\n  emptyText = \"No results\",\n  footer,\n  loop = true,\n  container,\n  className = \"\",\n  children,\n}: CommandPaletteProps) {\n  const mac = useIsMac();\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n  // Opened from the hotkey: no enter animation (rule 84). Any other opening animates.\n  const [instant, setInstant] = useState(false);\n  // Where focus was when the hotkey opened it, so it goes back there rather than to a trigger.\n  const returnTo = useRef<HTMLElement | null>(null);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      // A later opening from anywhere but the hotkey (a controlled `open`, `show()`) animates.\n      if (!next) setInstant(false);\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  const ownsHotkey = useHotkeyOwner(hotkey);\n  const parsed = useMemo(() => (hotkey ? parseHotkey(hotkey) : null), [hotkey]);\n\n  useEffect(() => {\n    if (!parsed || !ownsHotkey) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.repeat || e.isComposing || !matchesHotkey(e, parsed, mac)) return;\n      // Bare keys (\"/\") leave typing alone; chords (⌘K) work from any field.\n      if (!parsed.mod && !parsed.ctrl && !parsed.meta && isEditable(e.target)) return;\n      e.preventDefault();\n      if (open) {\n        setOpen(false);\n        return;\n      }\n      returnTo.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;\n      setInstant(true);\n      setOpen(true);\n    };\n    // Capture, so a host's own ⌘K handler that stops propagation can't swallow it.\n    window.addEventListener(\"keydown\", onKey, true);\n    return () => window.removeEventListener(\"keydown\", onKey, true);\n  }, [parsed, ownsHotkey, mac, open, setOpen]);\n\n  const groups = useMemo(() => groupsProp ?? [{ label: \"\", items: items ?? [] }], [groupsProp, items]);\n\n  return (\n    <Dialog.Root\n      open={open}\n      onOpenChange={(next) => {\n        if (next) {\n          setInstant(false);\n          returnTo.current = null;\n        }\n        setOpen(next);\n      }}\n    >\n      {children}\n      <Dialog.Portal container={container}>\n        <Dialog.Backdrop className={backdropClass} />\n        <Dialog.Viewport className=\"fixed inset-0 z-50 flex items-start justify-center px-4 pt-[15vh] max-sm:px-2 max-sm:pt-2\">\n          <Dialog.Popup\n            aria-label={label}\n            data-instant={instant || undefined}\n            finalFocus={() => (returnTo.current?.isConnected ? returnTo.current : true)}\n            className={`${modalSurface} ${modalMotion()} w-[36rem] max-w-full overflow-hidden ${className}`}\n          >\n            <PaletteBody\n              groups={groups}\n              placeholder={placeholder}\n              inputLabel={inputLabel}\n              ariaKeys={parsed && ownsHotkey ? ariaHotkey(parsed, mac) : undefined}\n              emptyText={emptyText}\n              footer={footer}\n              loop={loop}\n              onRun={(item) => {\n                item.onSelect();\n                setOpen(false);\n              }}\n            />\n          </Dialog.Popup>\n        </Dialog.Viewport>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n\ntype BodyProps = {\n  groups: CommandGroup[];\n  placeholder: string;\n  inputLabel: string;\n  ariaKeys?: string;\n  emptyText: CommandPaletteProps[\"emptyText\"];\n  footer: CommandPaletteProps[\"footer\"];\n  loop: boolean;\n  onRun: (item: CommandItem) => void;\n};\n\n/** Mounted with the popup, so the query resets on every open and the scroll edges track the live list. */\nfunction PaletteBody({ groups, placeholder, inputLabel, ariaKeys, emptyText, footer, loop, onRun }: BodyProps) {\n  const [query, setQuery] = useState(\"\");\n  const scroller = useRef<HTMLDivElement>(null);\n  const hintId = useId();\n  useScrollEdges(scroller);\n\n  const { contains } = Autocomplete.useFilter();\n  // Every word of the query must appear in the label, keywords, description or group name.\n  const searchText = useMemo(() => {\n    const map = new Map<CommandItem, string>();\n    for (const g of groups) {\n      for (const item of g.items) {\n        const description = typeof item.description === \"string\" ? item.description : \"\";\n        map.set(item, [item.label, ...(item.keywords ?? []), description, g.label].join(\" \"));\n      }\n    }\n    return map;\n  }, [groups]);\n  const filter = useCallback(\n    (item: CommandItem, q: string) => {\n      const words = q.trim().split(/\\s+/).filter(Boolean);\n      const text = searchText.get(item) ?? item.label;\n      return words.every((w) => contains(text, w));\n    },\n    [contains, searchText],\n  );\n\n  const empty = typeof emptyText === \"function\" ? emptyText(query.trim()) : emptyText;\n\n  return (\n    <Autocomplete.Root\n      open\n      inline\n      items={groups}\n      value={query}\n      onValueChange={setQuery}\n      filter={filter}\n      itemToStringValue={(item: CommandItem) => item.label}\n      autoHighlight=\"always\"\n      keepHighlight\n      loopFocus={loop}\n    >\n      {/* The 16px icon sits on the rows' icon line, 16px in (R104). */}\n      <Autocomplete.InputGroup className=\"flex h-12 shrink-0 cursor-text items-center gap-3 border-b border-line-subtle px-4\">\n        <SearchIcon />\n        <Autocomplete.Input\n          aria-label={inputLabel}\n          aria-describedby={hintId}\n          aria-keyshortcuts={ariaKeys}\n          placeholder={placeholder}\n          className=\"h-full min-w-0 flex-1 bg-transparent text-ui-md text-text outline-none placeholder:text-text-muted [&::-webkit-search-cancel-button]:appearance-none\"\n        />\n      </Autocomplete.InputGroup>\n      <span id={hintId} className=\"sr-only\">\n        Use the up and down arrows to move, Enter to run the highlighted command, Escape to close.\n      </span>\n      <Dialog.Close className=\"sr-only\">Close</Dialog.Close>\n\n      <div\n        ref={scroller}\n        style={scrollFadeMask(\"y\", 16)}\n        className=\"max-h-[min(60dvh,22rem)] min-h-0 overflow-y-auto overscroll-contain scroll-py-3 max-sm:max-h-[min(calc(55dvh-6rem),22rem)]\"\n      >\n        <Autocomplete.Empty className=\"px-2 [--pad:8px]\">\n          <p className=\"flex h-24 items-center justify-center text-center text-ui-sm text-text-muted\">{empty}</p>\n        </Autocomplete.Empty>\n        <Autocomplete.List className=\"grid grid-cols-[auto_minmax(0,1fr)_auto] content-start p-2 [--pad:8px] empty:hidden\">\n          {(group: CommandGroup, index: number) => (\n            <Autocomplete.Group key={group.label || index} items={group.items} className={`${groupClass} not-first:mt-2`}>\n              {group.label ? (\n                <Autocomplete.GroupLabel className={groupLabelClass}>{group.label}</Autocomplete.GroupLabel>\n              ) : (\n                index > 0 && <div role=\"separator\" className={separatorClass} />\n              )}\n              <Autocomplete.Collection>\n                {(item: CommandItem) => (\n                  <Autocomplete.Item\n                    key={item.id}\n                    value={item}\n                    disabled={item.disabled}\n                    onClick={() => onRun(item)}\n                    className={`${itemClass()} min-h-10! max-sm:min-h-11!`}\n                  >\n                    <ItemParts\n                      icon={item.icon}\n                      trailing={\n                        item.description !== undefined || item.shortcut ? (\n                          <>\n                            {item.description !== undefined && <span className=\"truncate text-ui-xs text-text-muted\">{item.description}</span>}\n                            {item.shortcut && <Kbd keys={item.shortcut} listen={false} />}\n                          </>\n                        ) : undefined\n                      }\n                    >\n                      <Highlighted text={item.label} query={query} />\n                    </ItemParts>\n                  </Autocomplete.Item>\n                )}\n              </Autocomplete.Collection>\n            </Autocomplete.Group>\n          )}\n        </Autocomplete.List>\n      </div>\n\n      {footer !== false && (\n        <div className=\"flex h-10 shrink-0 items-center gap-4 border-t border-line-subtle px-4 text-ui-xs text-text-muted\">\n          {footer ?? <DefaultFooter />}\n        </div>\n      )}\n    </Autocomplete.Root>\n  );\n}\n\n/** The label with the query's first match in full text colour and the rest secondary. No match: all full colour. */\nfunction Highlighted({ text, query }: { text: string; query: string }) {\n  const q = query.trim().toLocaleLowerCase();\n  const at = q ? text.toLocaleLowerCase().indexOf(q) : -1;\n  if (at < 0) return <>{text}</>;\n  return (\n    <span className=\"text-text-secondary\">\n      {text.slice(0, at)}\n      <span className=\"text-text\">{text.slice(at, at + q.length)}</span>\n      {text.slice(at + q.length)}\n    </span>\n  );\n}\n\n/** ↑↓ Navigate · ↵ Open · Esc Close. The caps press as the real keys do. Hidden from screen readers, who get the input's description. */\nfunction DefaultFooter() {\n  return (\n    <div aria-hidden className=\"flex items-center gap-4\">\n      <span className=\"flex items-center gap-1.5\">\n        <Kbd keys={[\"↑\", \"↓\"]} />\n        Navigate\n      </span>\n      <span className=\"flex items-center gap-1.5\">\n        <Kbd>↵</Kbd>\n        Open\n      </span>\n      <span className=\"flex items-center gap-1.5\">\n        <Kbd>Esc</Kbd>\n        Close\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------- Trigger ---------- */\n\nexport type CommandPaletteTriggerProps = Omit<ComponentProps<typeof Dialog.Trigger>, \"className\" | \"children\"> & {\n  /** The placeholder-style label. Default \"Search…\". */\n  children?: ReactNode;\n  /** The hotkey to show as caps, matching the palette's. Default \"mod+k\"; false hides the hint. */\n  hotkey?: string | false;\n  className?: string;\n};\n\n/**\n * A button dressed as a search field: magnifier, muted \"Search…\", and the ⌘K caps (Ctrl K off\n * Apple). It presses by colour and never scales (R64). Put it inside `CommandPalette`.\n */\nexport function CommandPaletteTrigger({ children = \"Search…\", hotkey = \"mod+k\", className = \"\", ...props }: CommandPaletteTriggerProps) {\n  const mac = useIsMac();\n  return (\n    <Dialog.Trigger\n      {...props}\n      className={`focus-ring flex h-control-sm min-w-0 cursor-pointer items-center gap-2 rounded-control-sm border border-line bg-surface ps-2.5 pe-1.5 text-ui-sm text-text-muted select-none [transition:color_150ms_var(--eased-ease-standard),background-color_150ms_var(--eased-ease-standard),border-color_150ms_var(--eased-ease-standard),box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] hover-fine:border-line-strong hover-fine:text-text-secondary active:bg-surface-hover data-popup-open:bg-surface-hover ${className}`}\n    >\n      <SearchIcon />\n      <span className=\"min-w-0 flex-1 truncate text-start\">{children}</span>\n      {hotkey && <Kbd keys={hotkeyCaps(hotkey, mac)} listen={false} className=\"ms-2\" />}\n    </Dialog.Trigger>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4 shrink-0 text-text-muted\">\n      <circle cx=\"7.25\" cy=\"7.25\" r=\"4.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"m10.75 10.75 2.5 2.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/command-palette/command-palette.tsx"
    }
  ],
  "meta": {
    "details": "A ⌘K command palette that rises 8px at 0.96 on a spring when clicked open and appears instantly from the hotkey, then leaves 4px in 160ms. Enter runs the first match.",
    "industries": [
      "saas",
      "devtools",
      "ai",
      "legal"
    ],
    "releaseAt": "2026-10-21"
  },
  "type": "registry:ui"
}