{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "title": "Command Palette",
  "description": "A ready-made ⌘K command palette: one component you drop in, open with a keyboard shortcut, and fill with actions. Use it for global search, jump-to-page navigation, quick actions and power-user shortcuts in dashboards, admin panels, editors, docs sites and any app that has outgrown its nav bar. Common asks it answers: \"command palette\", \"cmd+k menu\", \"ctrl+k search\", \"command menu\", \"quick switcher\", \"spotlight-style search\", \"raycast-style launcher\", \"jump to anything\", \"action launcher\", \"global search dialog\", \"cmdk alternative\", \"command palette without cmdk\". shadcn/ui does ship command, and the difference is what you get handed: that one is nine primitives — Command, CommandDialog, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, CommandSeparator, CommandShortcut — wrapping the cmdk npm package and pulling in the dialog item, which you then assemble into a palette yourself. This is a single component that depends on nothing but lucide-react: no cmdk, no dialog, no assembly. It arrives with the parts that are otherwise left to you — recently used entries surfaced when the input is empty, fuzzy filtering that highlights the matched characters in each result, grouped sections, wrap-around arrow-key navigation, and an async source hook so results can come from your own endpoint instead of a hard-coded array. The fiddly part of a palette is not the list, it is the focus: opening it traps focus so Tab cannot wander into the page behind, closing it puts focus back on whatever the reader was on, and the highlighted row is exposed with combobox and listbox roles plus aria-activedescendant, so the active option is announced while the text cursor stays in the input where typing belongs. If you would rather not run search infrastructure, the exported pulldSearchSource helper points the same async source at pulld Search for hosted semantic results.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/command-palette.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2, Search } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface CommandItem {\n  id: string\n  label: string\n  group?: string\n  /** Extra text matched against the query (synonyms, ids, etc.). */\n  keywords?: string\n  /** Keys shown on the right, e.g. [\"⌘\", \"P\"]. */\n  shortcut?: string[]\n  icon?: React.ReactNode\n  onSelect?: () => void\n}\n\ninterface CommandPaletteProps {\n  /** Static commands. Ignored when `source` is provided. */\n  items?: CommandItem[]\n  /**\n   * Async source for the current query — return the items to show. This is the\n   * integration point for server-side or semantic search. The function identity\n   * may change between renders; it is read from a ref, so an inline arrow is safe.\n   *\n   * For hosted semantic search with no infra to run, use the `pulldSearchSource`\n   * helper exported below — `source={pulldSearchSource({ queryKey: \"pk_...\" })}`.\n   */\n  source?: (query: string) => Promise<CommandItem[]>\n  placeholder?: string\n  emptyMessage?: string\n  /** Key combined with Cmd/Ctrl to toggle the palette. Default \"k\". */\n  hotkey?: string\n  /** localStorage key to remember recently selected items. Omit to disable. */\n  recentsKey?: string\n  /** Max results rendered at once (older entries are hidden behind a hint). */\n  maxResults?: number\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n}\n\nfunction useDebounced<T>(value: T, ms: number): T {\n  const [v, setV] = React.useState(value)\n  React.useEffect(() => {\n    if (ms <= 0) {\n      setV(value)\n      return\n    }\n    const id = window.setTimeout(() => setV(value), ms)\n    return () => window.clearTimeout(id)\n  }, [value, ms])\n  return v\n}\n\n// Subsequence fuzzy score: null when not a match, otherwise lower = better.\nfunction fuzzyScore(text: string, q: string): number | null {\n  if (!q) return 0\n  const t = text.toLowerCase()\n  const query = q.toLowerCase()\n  let from = 0\n  let score = 0\n  let prev = -1\n  for (const c of query) {\n    const idx = t.indexOf(c, from)\n    if (idx === -1) return null\n    score += idx - from\n    if (prev !== -1 && idx !== prev + 1) score += 1\n    prev = idx\n    from = idx + 1\n  }\n  return score\n}\n\n// Highlight the same subsequence positions the scorer matched on.\nfunction highlight(label: string, query: string): React.ReactNode {\n  const q = query.trim().toLowerCase()\n  if (!q) return label\n  const lower = label.toLowerCase()\n  const marks = new Array<boolean>(label.length).fill(false)\n  let from = 0\n  for (const c of q) {\n    const idx = lower.indexOf(c, from)\n    if (idx === -1) return label // not a subsequence (e.g. async result) → no highlight\n    marks[idx] = true\n    from = idx + 1\n  }\n  const parts: React.ReactNode[] = []\n  let i = 0\n  while (i < label.length) {\n    const on = marks[i]\n    let j = i\n    while (j < label.length && marks[j] === on) j++\n    const chunk = label.slice(i, j)\n    parts.push(\n      on ? (\n        <mark key={i} className=\"bg-transparent font-semibold text-foreground\">\n          {chunk}\n        </mark>\n      ) : (\n        <React.Fragment key={i}>{chunk}</React.Fragment>\n      )\n    )\n    i = j\n  }\n  return <>{parts}</>\n}\n\nexport function CommandPalette({\n  items = [],\n  source,\n  placeholder = \"Type a command or search…\",\n  emptyMessage = \"No results.\",\n  hotkey = \"k\",\n  recentsKey,\n  maxResults = 50,\n  open: openProp,\n  onOpenChange,\n}: CommandPaletteProps) {\n  const [openState, setOpenState] = React.useState(false)\n  const open = openProp ?? openState\n  const setOpen = React.useCallback(\n    (v: boolean) => {\n      onOpenChange?.(v)\n      if (openProp === undefined) setOpenState(v)\n    },\n    [onOpenChange, openProp]\n  )\n\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n  const [asyncItems, setAsyncItems] = React.useState<CommandItem[] | null>(null)\n  const [loading, setLoading] = React.useState(false)\n  const debouncedQuery = useDebounced(query, source ? 180 : 0)\n\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const restoreRef = React.useRef<HTMLElement | null>(null)\n  const openedRef = React.useRef(false)\n  const reqId = React.useRef(0)\n\n  // Keep the latest `source` in a ref so an inline arrow doesn't re-fire the fetch effect.\n  const sourceRef = React.useRef(source)\n  React.useEffect(() => {\n    sourceRef.current = source\n  }, [source])\n  const hasSource = !!source\n\n  // Global hotkey to toggle (ignore auto-repeat so a held key doesn't flicker it).\n  React.useEffect(() => {\n    function onKey(e: KeyboardEvent) {\n      if (e.repeat) return\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {\n        e.preventDefault()\n        setOpen(!open)\n      }\n    }\n    window.addEventListener(\"keydown\", onKey)\n    return () => window.removeEventListener(\"keydown\", onKey)\n  }, [hotkey, open, setOpen])\n\n  // Focus the input on open; lock body scroll; restore the previous focus on close.\n  React.useEffect(() => {\n    if (open) {\n      openedRef.current = true\n      restoreRef.current = document.activeElement as HTMLElement | null\n      setQuery(\"\")\n      setActive(0)\n      const prevOverflow = document.body.style.overflow\n      document.body.style.overflow = \"hidden\"\n      const id = window.setTimeout(() => inputRef.current?.focus(), 0)\n      return () => {\n        window.clearTimeout(id)\n        document.body.style.overflow = prevOverflow\n      }\n    }\n    if (openedRef.current) {\n      openedRef.current = false\n      const el = restoreRef.current\n      if (el && el.isConnected) el.focus()\n    }\n  }, [open])\n\n  // Async source, keyed only on the query + open (source read from ref); stale-guarded.\n  React.useEffect(() => {\n    const src = sourceRef.current\n    if (!src || !open) return\n    const myId = ++reqId.current\n    setLoading(true)\n    Promise.resolve(src(debouncedQuery))\n      .then((res) => {\n        if (reqId.current === myId) {\n          setAsyncItems(res)\n          setLoading(false)\n        }\n      })\n      .catch(() => {\n        if (reqId.current === myId) {\n          setAsyncItems([])\n          setLoading(false)\n        }\n      })\n  }, [debouncedQuery, open])\n\n  const recents = React.useMemo<string[]>(() => {\n    if (!recentsKey || typeof window === \"undefined\") return []\n    try {\n      const v = JSON.parse(window.localStorage.getItem(recentsKey) || \"[]\")\n      return Array.isArray(v) ? v : []\n    } catch {\n      return []\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [recentsKey, open])\n\n  const results = React.useMemo<CommandItem[]>(() => {\n    if (hasSource) return asyncItems ?? []\n    if (!query) {\n      const byId = new Map(items.map((i) => [i.id, i]))\n      const recent = recents\n        .map((id) => byId.get(id))\n        .filter((x): x is CommandItem => Boolean(x))\n      const rest = items.filter((i) => !recents.includes(i.id))\n      return [...recent, ...rest]\n    }\n    return items\n      .map((i) => ({ i, s: fuzzyScore(`${i.label} ${i.keywords ?? \"\"}`, query) }))\n      .filter((x): x is { i: CommandItem; s: number } => x.s !== null)\n      .sort((a, b) => a.s - b.s)\n      .map((x) => x.i)\n  }, [items, asyncItems, hasSource, query, recents])\n\n  // Cluster items by group in first-seen order so each group renders contiguously.\n  // The render below re-buckets `shown` by group, so keyboard nav (which keys off the\n  // `shown` index) and rendering only agree when `shown` is already grouped — otherwise\n  // the highlighted / aria-active / scrolled row and the row Enter selects diverge for\n  // groups that are scattered in the results order.\n  const ordered = React.useMemo<CommandItem[]>(() => {\n    let hasGroup = false\n    const buckets = new Map<string, CommandItem[]>()\n    for (const it of results) {\n      if (it.group) hasGroup = true\n      const key = it.group ?? \"\"\n      const arr = buckets.get(key)\n      if (arr) arr.push(it)\n      else buckets.set(key, [it])\n    }\n    // No groups in play → keep the original (score / recents) order untouched.\n    return hasGroup ? Array.from(buckets.values()).flat() : results\n  }, [results])\n\n  // Only render up to maxResults; everything (nav, aria) is based on this list.\n  const shown = ordered.slice(0, Math.max(1, maxResults))\n  // Clamp the active index during render so aria-activedescendant always resolves.\n  const safeActive = shown.length ? Math.min(Math.max(0, active), shown.length - 1) : 0\n\n  React.useEffect(() => {\n    listRef.current\n      ?.querySelector<HTMLElement>(`[data-idx=\"${safeActive}\"]`)\n      ?.scrollIntoView({ block: \"nearest\" })\n  }, [safeActive])\n\n  const select = React.useCallback(\n    (item: CommandItem | undefined) => {\n      if (!item) return\n      if (recentsKey) {\n        try {\n          const next = [item.id, ...recents.filter((id) => id !== item.id)].slice(0, 6)\n          window.localStorage.setItem(recentsKey, JSON.stringify(next))\n        } catch {\n          /* ignore */\n        }\n      }\n      setOpen(false)\n      item.onSelect?.()\n    },\n    [recents, recentsKey, setOpen]\n  )\n\n  // All keys handled on the dialog so they work regardless of which child has focus.\n  function onDialogKeyDown(e: React.KeyboardEvent) {\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault()\n        setActive((a) => (shown.length ? (a + 1) % shown.length : 0))\n        break\n      case \"ArrowUp\":\n        e.preventDefault()\n        setActive((a) => (shown.length ? (a - 1 + shown.length) % shown.length : 0))\n        break\n      case \"Enter\":\n        e.preventDefault()\n        select(shown[safeActive])\n        break\n      case \"Escape\":\n        e.preventDefault()\n        setOpen(false)\n        break\n      case \"Home\":\n        e.preventDefault()\n        setActive(0)\n        break\n      case \"End\":\n        e.preventDefault()\n        setActive(shown.length - 1)\n        break\n      case \"Tab\":\n        // Trap focus: the palette is driven by arrows, so keep focus on the input.\n        e.preventDefault()\n        inputRef.current?.focus()\n        break\n      default:\n        break\n    }\n  }\n\n  if (!open) return null\n\n  const groups = new Map<string, CommandItem[]>()\n  for (const it of shown) {\n    const g = it.group ?? \"\"\n    const arr = groups.get(g)\n    if (arr) arr.push(it)\n    else groups.set(g, [it])\n  }\n\n  let flatIndex = -1\n\n  return (\n    <div\n      role=\"presentation\"\n      onClick={(e) => {\n        if (e.target === e.currentTarget) setOpen(false)\n      }}\n      className=\"fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[15vh]\"\n    >\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n      <div\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Command palette\"\n        onKeyDown={onDialogKeyDown}\n        className=\"w-full max-w-xl overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-2xl\"\n      >\n        <div className=\"flex items-center gap-2 border-b px-3\">\n          <Search className=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n          <input\n            ref={inputRef}\n            value={query}\n            onChange={(e) => {\n              setQuery(e.target.value)\n              setActive(0)\n            }}\n            placeholder={placeholder}\n            role=\"combobox\"\n            aria-expanded\n            aria-controls=\"pulld-cmd-list\"\n            aria-activedescendant={shown.length ? `pulld-cmd-${safeActive}` : undefined}\n            aria-autocomplete=\"list\"\n            className=\"h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n          />\n          {loading ? (\n            <Loader2 className=\"h-4 w-4 shrink-0 animate-spin text-muted-foreground\" aria-hidden=\"true\" />\n          ) : null}\n        </div>\n\n        <div className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          {loading\n            ? \"Searching…\"\n            : `${results.length} result${results.length === 1 ? \"\" : \"s\"}`}\n        </div>\n\n        <div\n          ref={listRef}\n          id=\"pulld-cmd-list\"\n          role=\"listbox\"\n          aria-label=\"Results\"\n          aria-busy={loading}\n          className=\"max-h-80 overflow-y-auto p-2\"\n        >\n          {shown.length === 0 ? (\n            <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n              {loading ? \"Searching…\" : emptyMessage}\n            </div>\n          ) : (\n            [...groups.entries()].map(([group, gItems]) => (\n              <div key={group || \"_\"} role=\"group\" aria-label={group || undefined}>\n                {group ? (\n                  <div className=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\">\n                    {group}\n                  </div>\n                ) : null}\n                {gItems.map((it) => {\n                  flatIndex += 1\n                  const i = flatIndex\n                  const isActive = i === safeActive\n                  return (\n                    <div\n                      key={it.id}\n                      id={`pulld-cmd-${i}`}\n                      data-idx={i}\n                      role=\"option\"\n                      aria-selected={isActive}\n                      onMouseMove={() => setActive(i)}\n                      onClick={() => select(it)}\n                      className={cn(\n                        \"flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm\",\n                        isActive ? \"bg-accent text-accent-foreground\" : \"text-foreground\"\n                      )}\n                    >\n                      {it.icon ? (\n                        <span className=\"shrink-0 text-muted-foreground\">{it.icon}</span>\n                      ) : null}\n                      <span className=\"flex-1 truncate\">{highlight(it.label, query)}</span>\n                      {it.shortcut?.length ? (\n                        <span className=\"flex shrink-0 gap-1\">\n                          {it.shortcut.map((k, j) => (\n                            <kbd\n                              key={j}\n                              className=\"rounded border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground\"\n                            >\n                              {k}\n                            </kbd>\n                          ))}\n                        </span>\n                      ) : null}\n                    </div>\n                  )\n                })}\n              </div>\n            ))\n          )}\n          {results.length > shown.length ? (\n            <div className=\"px-3 py-2 text-center text-xs text-muted-foreground\">\n              Showing {shown.length} of {results.length} — keep typing to narrow.\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  )\n}\n\n/** A single result from the pulld Search query endpoint. */\ninterface PulldSearchResult {\n  id: string\n  label: string\n  url: string\n  snippet: string\n  score: number\n}\n\n/**\n * Optional: a drop-in `source` backed by pulld Search — hosted semantic (meaning-based)\n * search with no infra to run. Subscribe at https://pulld.pages.dev, index your content,\n * then wire it in one line:\n *\n *   <CommandPalette source={pulldSearchSource({ queryKey: \"pk_your_public_key\" })} />\n *\n * It calls the public query endpoint and maps each result to a command item that\n * navigates to the result's URL on select. `queryKey` is the public, read-only key\n * (safe to ship in client code); pass `onSelect` to handle results yourself (e.g.\n * client-side routing) instead of a full-page navigation.\n *\n * To make results appear you must first index your content. Full integration guide\n * (keys, ingest, keeping the index in sync): https://pulld.pages.dev/search-integration.md\n */\nexport function pulldSearchSource(opts: {\n  queryKey: string\n  /** Override the endpoint, e.g. when serving pulld Search from your own domain. */\n  endpoint?: string\n  /** Max results to request (default 8). */\n  limit?: number\n  onSelect?: (result: PulldSearchResult) => void\n}): (query: string) => Promise<CommandItem[]> {\n  const endpoint = opts.endpoint ?? \"https://pulld.pages.dev/api/search/query\"\n  const limit = opts.limit ?? 8\n  return async (query) => {\n    // Fail soft: a search backend hiccup yields no results rather than breaking the palette.\n    try {\n      const url = `${endpoint}?key=${encodeURIComponent(opts.queryKey)}&q=${encodeURIComponent(\n        query\n      )}&limit=${limit}`\n      const res = await fetch(url)\n      if (!res.ok) return []\n      const data = (await res.json()) as { results?: PulldSearchResult[] }\n      return (data.results ?? []).map((r) => ({\n        id: r.id,\n        label: r.label,\n        keywords: r.snippet,\n        onSelect: opts.onSelect\n          ? () => opts.onSelect!(r)\n          : r.url\n            ? () => {\n                window.location.href = r.url\n              }\n            : undefined,\n      }))\n    } catch {\n      return []\n    }\n  }\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}