{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-palette","type":"registry:ui","title":"Command Palette","description":"A ⌘K palette on frosted glass with fuzzy search and grouped results; a spring highlight glides to the active row as you arrow through.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/command-palette.tsx","type":"registry:ui","target":"components/ui/command-palette.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { CornerDownLeft, Search, SearchX } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CommandItem {\n  id: string;\n  label: string;\n  /** Heading the item is listed under. Items without one go under \"Results\". */\n  group?: string;\n  icon?: React.ReactNode;\n  /** Keys shown on the right, e.g. [\"⌘\", \"B\"] or \"G P\". */\n  shortcut?: string | string[];\n  /** Extra words that should match, e.g. synonyms. */\n  keywords?: string[];\n  /** Secondary text shown after the label. */\n  hint?: string;\n  disabled?: boolean;\n  onSelect: () => void;\n}\n\nexport interface CommandPaletteProps {\n  items: CommandItem[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  placeholder?: string;\n  /** Letter that toggles the palette with ⌘ / Ctrl. `false` disables the global hotkey. */\n  hotkey?: string | false;\n  /** Close after an item is chosen. */\n  closeOnSelect?: boolean;\n  /** Shown when nothing matches. Receives the query. */\n  empty?: (query: string) => React.ReactNode;\n  className?: string;\n}\n\nexport interface FuzzyMatch {\n  score: number;\n  /** Indices of matched characters in the label (empty when the match came from keywords). */\n  indices: number[];\n}\n\n/**\n * Subsequence match with scoring: rewards consecutive runs, word starts and an early first hit,\n * penalises gaps. Returns null when `query` is not a subsequence of `text`.\n */\nexport function fuzzyMatch(query: string, text: string): FuzzyMatch | null {\n  const q = query.toLowerCase().replace(/\\s+/g, \"\");\n  if (!q) return { score: 0, indices: [] };\n  const t = text.toLowerCase();\n  const a = scan(q, t, true);\n  const b = scan(q, t, false);\n  if (!a && !b) return null;\n  const sa = a ? scoreOf(a, q, t) : -Infinity;\n  const sb = b ? scoreOf(b, q, t) : -Infinity;\n  return sa >= sb ? { score: sa, indices: a! } : { score: sb, indices: b! };\n}\n\nconst isBoundary = (t: string, i: number) => i === 0 || /[\\s\\-_/.:]/.test(t[i - 1]);\n\n/** Greedy subsequence scan. With `preferBoundary`, a word-start hit beats an earlier mid-word one. */\nfunction scan(q: string, t: string, preferBoundary: boolean): number[] | null {\n  const out: number[] = [];\n  let from = 0;\n  for (const ch of q) {\n    const prev = out.length ? out[out.length - 1] : -2;\n    let hit = -1;\n    if (t[prev + 1] === ch && prev >= 0) hit = prev + 1;\n    else {\n      const first = t.indexOf(ch, from);\n      if (first === -1) return null;\n      hit = first;\n      if (preferBoundary && !isBoundary(t, first)) {\n        for (let j = first + 1; j < t.length; j++) {\n          if (t[j] === ch && isBoundary(t, j)) {\n            hit = j;\n            break;\n          }\n        }\n      }\n    }\n    out.push(hit);\n    from = hit + 1;\n  }\n  return out;\n}\n\nfunction scoreOf(indices: number[], q: string, t: string) {\n  let score = 0;\n  let prev = -2;\n  for (const i of indices) {\n    score += 1;\n    if (i === prev + 1) score += 4;\n    if (isBoundary(t, i)) score += 3;\n    if (i === 0) score += 2;\n    if (prev >= 0) score -= Math.min(4, i - prev - 1) * 0.5;\n    prev = i;\n  }\n  if (t.startsWith(q)) score += 6;\n  return score - (t.length - q.length) * 0.02;\n}\n\nfunction matchItem(query: string, item: CommandItem): FuzzyMatch | null {\n  const label = fuzzyMatch(query, item.label);\n  let best = label;\n  for (const k of item.keywords ?? []) {\n    const m = fuzzyMatch(query, k);\n    if (m && (!best || m.score - 1 > best.score)) best = { score: m.score - 1, indices: [] };\n  }\n  return best;\n}\n\nconst subscribe = () => () => {};\n\n/** ⌘K command palette on glass: fuzzy filter, grouped results and a spring highlight that glides between rows. */\nexport function CommandPalette({\n  items,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  placeholder = \"Type a command or search\",\n  hotkey = \"k\",\n  closeOnSelect = true,\n  empty,\n  className,\n}: CommandPaletteProps) {\n  const [inner, setInner] = React.useState(defaultOpen);\n  const open = openProp ?? inner;\n  const mounted = React.useSyncExternalStore(subscribe, () => true, () => false);\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  React.useEffect(() => {\n    if (!hotkey) return;\n    function onKey(e: KeyboardEvent) {\n      if (e.defaultPrevented || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;\n      if (e.key.toLowerCase() !== (hotkey as string).toLowerCase()) return;\n      e.preventDefault();\n      setOpen(!open);\n    }\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [hotkey, open, setOpen]);\n\n  if (!mounted) return null;\n  return createPortal(\n    <AnimatePresence>\n      {open && (\n        <PaletteDialog\n          key=\"palette\"\n          items={items}\n          placeholder={placeholder}\n          closeOnSelect={closeOnSelect}\n          empty={empty}\n          className={className}\n          onClose={() => setOpen(false)}\n        />\n      )}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\ninterface PaletteDialogProps {\n  items: CommandItem[];\n  placeholder: string;\n  closeOnSelect: boolean;\n  empty?: (query: string) => React.ReactNode;\n  className?: string;\n  onClose: () => void;\n}\n\ninterface Row {\n  item: CommandItem;\n  match: FuzzyMatch;\n}\n\nfunction PaletteDialog({ items, placeholder, closeOnSelect, empty, className, onClose }: PaletteDialogProps) {\n  const uid = React.useId();\n  const reduce = useReducedMotion();\n  const [query, setQuery] = React.useState(\"\");\n  const [active, setActive] = React.useState(0);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const keyboard = React.useRef(false);\n\n  const groups = React.useMemo(() => {\n    const rows: Row[] = [];\n    for (const item of items) {\n      const match = query ? matchItem(query, item) : { score: 0, indices: [] };\n      if (match) rows.push({ item, match });\n    }\n    const map = new Map<string, Row[]>();\n    for (const r of rows) {\n      const g = r.item.group ?? \"Results\";\n      const list = map.get(g) ?? [];\n      list.push(r);\n      map.set(g, list);\n    }\n    const out = Array.from(map, ([name, list]) => ({\n      name,\n      rows: query ? [...list].sort((a, b) => b.match.score - a.match.score) : list,\n    }));\n    if (query) out.sort((a, b) => b.rows[0].match.score - a.rows[0].match.score);\n    return out;\n  }, [items, query]);\n\n  const flat = React.useMemo(() => groups.flatMap((g) => g.rows), [groups]);\n  const activeRow = flat[Math.min(active, flat.length - 1)];\n  const optionId = (id: string) => `${uid}-opt-${id}`;\n\n  // Restore focus to whatever opened the palette.\n  React.useEffect(() => {\n    const prev = document.activeElement as HTMLElement | null;\n    const raf = requestAnimationFrame(() => inputRef.current?.focus());\n    return () => {\n      cancelAnimationFrame(raf);\n      prev?.focus?.({ preventScroll: true });\n    };\n  }, []);\n\n  // Keep the keyboard-selected row in view.\n  React.useEffect(() => {\n    if (!keyboard.current || !activeRow) return;\n    document.getElementById(optionId(activeRow.item.id))?.scrollIntoView({ block: \"nearest\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [activeRow]);\n\n  function choose(row: Row | undefined) {\n    if (!row || row.item.disabled) return;\n    row.item.onSelect();\n    if (closeOnSelect) onClose();\n  }\n\n  function move(delta: number) {\n    if (!flat.length) return;\n    keyboard.current = true;\n    let i = Math.min(active, flat.length - 1);\n    for (let n = 0; n < flat.length; n++) {\n      i = (i + delta + flat.length) % flat.length;\n      if (!flat[i].item.disabled) break;\n    }\n    setActive(i);\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowDown\" || (e.ctrlKey && e.key === \"n\")) {\n      e.preventDefault();\n      move(1);\n    } else if (e.key === \"ArrowUp\" || (e.ctrlKey && e.key === \"p\")) {\n      e.preventDefault();\n      move(-1);\n    } else if (e.key === \"Home\" && flat.length) {\n      e.preventDefault();\n      keyboard.current = true;\n      setActive(0);\n    } else if (e.key === \"End\" && flat.length) {\n      e.preventDefault();\n      keyboard.current = true;\n      setActive(flat.length - 1);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      choose(activeRow);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      if (query) {\n        setQuery(\"\");\n        setActive(0);\n      } else onClose();\n    } else if (e.key === \"Tab\") {\n      e.preventDefault();\n      inputRef.current?.focus();\n    }\n  }\n\n  let index = -1;\n  return (\n    <div className=\"fixed inset-0 z-[90] flex items-start justify-center px-4 pt-[14vh]\" onKeyDown={onKeyDown}>\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0 bg-scrim\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0 }}\n        transition={{ duration: 0.18 }}\n        onClick={onClose}\n      />\n      <motion.div\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Command palette\"\n        initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : -8, filter: reduce ? \"blur(0px)\" : \"blur(8px)\" }}\n        animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={{ opacity: 0, scale: reduce ? 1 : 0.97, y: reduce ? 0 : -4, filter: reduce ? \"blur(0px)\" : \"blur(6px)\", transition: { duration: 0.14 } }}\n        transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n        className={cn(\n          \"glass relative flex max-h-[min(520px,72vh)] w-full max-w-[580px] flex-col overflow-hidden rounded-[20px] border border-border text-ink shadow-lg\",\n          className,\n        )}\n      >\n        <div className=\"flex items-center gap-3 border-b border-border px-4\">\n          <Search className=\"size-[18px] shrink-0 text-ink-subtle\" aria-hidden />\n          <input\n            ref={inputRef}\n            role=\"combobox\"\n            aria-expanded=\"true\"\n            aria-controls={`${uid}-list`}\n            aria-autocomplete=\"list\"\n            aria-activedescendant={activeRow ? optionId(activeRow.item.id) : undefined}\n            aria-label={placeholder}\n            autoComplete=\"off\"\n            spellCheck={false}\n            value={query}\n            onChange={(e) => {\n              setQuery(e.target.value);\n              setActive(0);\n            }}\n            placeholder={placeholder}\n            className=\"h-14 min-w-0 flex-1 bg-transparent text-[15px] text-ink outline-none placeholder:text-ink-subtle\"\n          />\n          <kbd className=\"hidden h-6 items-center rounded-md border border-border bg-surface px-1.5 font-mono text-[11px] text-ink-muted sm:inline-flex\">\n            esc\n          </kbd>\n        </div>\n\n        <div\n          ref={listRef}\n          id={`${uid}-list`}\n          role=\"listbox\"\n          aria-label=\"Commands\"\n          className=\"no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-2\"\n          onPointerMove={() => {\n            keyboard.current = false;\n          }}\n        >\n          {flat.length === 0 ? (\n            <motion.div\n              initial={{ opacity: 0, y: 6, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              transition={{ duration: 0.24 }}\n              role=\"status\"\n              className=\"grid place-items-center gap-2 px-6 py-10 text-center\"\n            >\n              {empty ? (\n                empty(query)\n              ) : (\n                <>\n                  <span className=\"grid size-10 place-items-center rounded-[14px] border border-border bg-surface text-ink-subtle\">\n                    <SearchX className=\"size-[18px]\" aria-hidden />\n                  </span>\n                  <p className=\"text-sm font-medium text-ink\">No results for “{query}”</p>\n                  <p className=\"text-[13px] text-ink-muted\">Try a shorter word, or search by group name.</p>\n                </>\n              )}\n            </motion.div>\n          ) : (\n            groups.map((g) => (\n              <div key={g.name} role=\"group\" aria-labelledby={`${uid}-g-${g.name}`} className=\"mb-1 last:mb-0\">\n                <div id={`${uid}-g-${g.name}`} className=\"px-3 pb-1.5 pt-2.5 text-[11px] font-semibold uppercase tracking-[0.6px] text-ink-subtle\">\n                  {g.name}\n                </div>\n                {g.rows.map((row) => {\n                  index += 1;\n                  const i = index;\n                  const on = activeRow?.item.id === row.item.id;\n                  const keys = typeof row.item.shortcut === \"string\" ? row.item.shortcut.split(\" \") : row.item.shortcut;\n                  return (\n                    <div\n                      key={row.item.id}\n                      id={optionId(row.item.id)}\n                      role=\"option\"\n                      aria-selected={on}\n                      aria-disabled={row.item.disabled || undefined}\n                      onPointerMove={() => {\n                        if (!keyboard.current && active !== i && !row.item.disabled) setActive(i);\n                      }}\n                      onPointerDown={(e) => e.preventDefault()}\n                      onClick={() => choose(row)}\n                      className={cn(\n                        \"relative flex h-11 cursor-pointer select-none items-center gap-3 rounded-[12px] px-3 text-sm\",\n                        row.item.disabled && \"cursor-not-allowed opacity-45\",\n                      )}\n                    >\n                      {on && (\n                        <motion.span\n                          layoutId={`${uid}-active`}\n                          aria-hidden\n                          className=\"absolute inset-0 rounded-[12px] bg-surface-hover ring-1 ring-border\"\n                          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 38 }}\n                        />\n                      )}\n                      {on && (\n                        <motion.span\n                          layoutId={`${uid}-bar`}\n                          aria-hidden\n                          className=\"absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-volt\"\n                          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 38 }}\n                        />\n                      )}\n                      {row.item.icon && (\n                        <span className={cn(\"relative grid size-5 shrink-0 place-items-center transition-colors [&>svg]:size-4\", on ? \"text-ink\" : \"text-ink-subtle\")}>\n                          {row.item.icon}\n                        </span>\n                      )}\n                      <span className=\"relative min-w-0 flex-1 truncate\">\n                        <Highlight text={row.item.label} indices={row.match.indices} on={on} />\n                        {row.item.hint && <span className=\"ml-2 text-[13px] text-ink-subtle\">{row.item.hint}</span>}\n                      </span>\n                      {keys && keys.length > 0 && (\n                        <span className=\"relative flex shrink-0 items-center gap-1\">\n                          {keys.map((k, n) => (\n                            <kbd\n                              key={n}\n                              className=\"inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border bg-surface px-1 font-mono text-[11px] text-ink-muted\"\n                            >\n                              {k}\n                            </kbd>\n                          ))}\n                        </span>\n                      )}\n                      {on && !keys?.length && <CornerDownLeft aria-hidden className=\"relative size-3.5 shrink-0 text-ink-subtle\" />}\n                    </div>\n                  );\n                })}\n              </div>\n            ))\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-4 border-t border-border px-4 py-2.5 text-[12px] text-ink-subtle\">\n          <span className=\"inline-flex items-center gap-1.5\">\n            <Kbd>↑</Kbd>\n            <Kbd>↓</Kbd> to navigate\n          </span>\n          <span className=\"inline-flex items-center gap-1.5\">\n            <Kbd>↵</Kbd> to select\n          </span>\n          <span className=\"ml-auto tabular-nums\" aria-live=\"polite\">\n            {flat.length} {flat.length === 1 ? \"result\" : \"results\"}\n          </span>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return (\n    <kbd className=\"inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border bg-surface px-1 font-mono text-[11px] text-ink-muted\">\n      {children}\n    </kbd>\n  );\n}\n\nfunction Highlight({ text, indices, on }: { text: string; indices: number[]; on: boolean }) {\n  const base = on ? \"text-ink\" : \"text-ink-muted\";\n  if (!indices.length) return <span className={base}>{text}</span>;\n  const set = new Set(indices);\n  return (\n    <span className={base} aria-label={text}>\n      {text.split(\"\").map((c, i) =>\n        set.has(i) ? (\n          <span key={i} aria-hidden className=\"font-semibold text-volt-ink\">\n            {c}\n          </span>\n        ) : (\n          <span key={i} aria-hidden>\n            {c}\n          </span>\n        ),\n      )}\n    </span>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","command palette","search","cmdk","keyboard","fuzzy search","combobox","overlay"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/command-palette"}}