{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-palette","type":"registry:component","title":"Command palette","description":"Opens on ⌘K and filters commands as you type.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/command-palette.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Opened constantly, so the entrance is barely there: fast enough never to\n// feel like waiting, just enough that the panel arrives rather than blinks.\nconst OPEN = { duration: 0.15, ease: EASE_OUT };\n// Closing is quicker still; nobody watches a palette leave.\nconst CLOSE = { duration: 0.1, ease: EASE_OUT };\n\nexport type Command = {\n  id: string;\n  label: string;\n  group: string;\n  icon: React.ReactNode;\n  shortcut?: string[];\n};\n\ntype Match = { command: Command; ranges: [number, number][] };\n\n// Contiguous substring first, so \"set\" highlights \"Settings\" as one run\n// instead of scattered letters; subsequence is the fallback (\"gtst\").\nfunction match(label: string, query: string): [number, number][] | null {\n  if (!query) return [];\n  const hay = label.toLowerCase();\n  const needle = query.toLowerCase();\n  const at = hay.indexOf(needle);\n  if (at !== -1) return [[at, at + needle.length]];\n  const ranges: [number, number][] = [];\n  let from = 0;\n  for (const char of needle) {\n    const i = hay.indexOf(char, from);\n    if (i === -1) return null;\n    const last = ranges[ranges.length - 1];\n    if (last && last[1] === i) last[1] = i + 1;\n    else ranges.push([i, i + 1]);\n    from = i + 1;\n  }\n  return ranges;\n}\n\nconst subscribeNoop = () => () => {};\nfunction useIsMac() {\n  // Server renders \"Ctrl\"; the client corrects it without a hydration mismatch.\n  return useSyncExternalStore(\n    subscribeNoop,\n    () => /Mac|iPhone|iPad/.test(navigator.platform),\n    () => false,\n  );\n}\n\n// The portal needs document.body, which only exists on the client.\nfunction useIsClient() {\n  return useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  );\n}\n\n/**\n * Opened many times a day, so every animation stays tiny: a 150ms entrance,\n * a 100ms exit, and the active item moves instantly with the keys. Anything\n * longer would be paid on every use.\n */\nexport function CommandPalette({\n  commands,\n  onRun,\n  placeholder = \"Type a command or search\",\n  className,\n}: {\n  commands: Command[];\n  onRun?: (command: Command) => void;\n  placeholder?: string;\n  className?: string;\n}) {\n  const isMac = useIsMac();\n  const isClient = useIsClient();\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  // Last pointer position, so a list scrolling under a still cursor (which\n  // browsers can report as hover) never steals the active item from the keys.\n  const pointer = useRef<{ x: number; y: number } | null>(null);\n\n  const results = useMemo(() => {\n    const matches: Match[] = [];\n    for (const command of commands) {\n      const ranges = match(command.label, query.trim());\n      if (ranges) matches.push({ command, ranges });\n    }\n    const groups = [...new Set(matches.map((m) => m.command.group))];\n    return groups.map((group) => ({\n      group,\n      items: matches.filter((m) => m.command.group === group),\n    }));\n  }, [commands, query]);\n\n  // Flattened in rendered order, so arrow keys and indices always agree even\n  // when commands arrive with their groups interleaved.\n  const flat = useMemo(() => results.flatMap((g) => g.items), [results]);\n  const activeIndex = Math.min(active, Math.max(flat.length - 1, 0));\n  const activeItem = flat[activeIndex];\n  const optionId = useCallback(\n    (command: Command) => `${id}-option-${command.id}`,\n    [id],\n  );\n\n  const show = useCallback(() => {\n    setQuery(\"\");\n    setActive(0);\n    pointer.current = null;\n    setOpen(true);\n  }, []);\n\n  const close = useCallback(() => {\n    setOpen(false);\n    triggerRef.current?.focus();\n  }, []);\n\n  const run = (command: Command) => {\n    close();\n    onRun?.(command);\n  };\n\n  useEffect(() => {\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key.toLowerCase() !== \"k\" || !(e.metaKey || e.ctrlKey)) return;\n      if (e.altKey || e.shiftKey || e.repeat) return;\n      // Inert copies, like the index preview, stay quiet.\n      if (triggerRef.current?.closest(\"[inert]\")) return;\n      // Browsers bind Ctrl+K to address bar search. Marking it handled also\n      // tells the site's own search to stand aside on this page.\n      e.preventDefault();\n      if (open) close();\n      else show();\n    };\n    // Capture, so it runs before the site's search, which listens later.\n    window.addEventListener(\"keydown\", onKeyDown, true);\n    return () => window.removeEventListener(\"keydown\", onKeyDown, true);\n  }, [open, close, show]);\n\n  useEffect(() => {\n    if (!open || !activeItem) return;\n    // Smooth, so stepping past the edge glides the list along rather than\n    // jumping it. \"nearest\" means it only scrolls when the item is out of view.\n    document.getElementById(optionId(activeItem.command))?.scrollIntoView({\n      block: \"nearest\",\n      behavior: reduceMotion ? \"auto\" : \"smooth\",\n    });\n  }, [open, activeItem, optionId, reduceMotion]);\n\n  const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    const count = flat.length;\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      if (!count) return;\n      const step = e.key === \"ArrowDown\" ? 1 : -1;\n      setActive((activeIndex + step + count) % count);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (activeItem) run(activeItem.command);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      close();\n    } else if (e.key === \"Tab\") {\n      // The input is the dialog's only tab stop; keep focus inside.\n      e.preventDefault();\n    }\n  };\n\n  const onOptionPointerMove = (e: React.PointerEvent, index: number) => {\n    if (e.pointerType === \"touch\") return;\n    const last = pointer.current;\n    if (last && last.x === e.clientX && last.y === e.clientY) return;\n    pointer.current = { x: e.clientX, y: e.clientY };\n    if (index !== activeIndex) setActive(index);\n  };\n\n  let index = -1;\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-keyshortcuts={isMac ? \"Meta+K\" : \"Control+K\"}\n        onClick={show}\n        className={cn(\n          \"flex h-9 w-60 touch-manipulation items-center gap-2 rounded-lg bg-muted pr-1.5 pl-3 text-sm text-muted-foreground shadow-raised outline-hidden transition-[scale,color] duration-150 ease-out select-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color]\",\n          className,\n        )}\n      >\n        <SearchIcon />\n        <span className=\"flex-1 text-left\">Search commands</span>\n        <Kbd keys={[isMac ? \"⌘\" : \"Ctrl\", \"K\"]} />\n      </button>\n\n      {isClient &&\n        createPortal(\n          <AnimatePresence>\n            {open && (\n              <motion.div\n                className=\"fixed inset-0 z-50\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1, transition: OPEN }}\n                exit={{ opacity: 0, transition: CLOSE }}\n              >\n                {/* Token-only dim: a grey wash in light, near black in dark. */}\n                <div\n                  aria-hidden\n                  className=\"absolute inset-0 bg-foreground/10 dark:bg-background/70\"\n                  onClick={close}\n                />\n                <motion.div\n                  // Grows from its top edge, where the eye already is at the input.\n                  initial={reduceMotion ? false : { scale: 0.98 }}\n                  animate={{ scale: 1, transition: OPEN }}\n                  exit={\n                    reduceMotion\n                      ? undefined\n                      : { scale: 0.98, transition: CLOSE }\n                  }\n                  role=\"dialog\"\n                  aria-modal=\"true\"\n                  aria-label=\"Command palette\"\n                  // Clicks anywhere in the panel keep focus in the input, so\n                  // typing and arrow keys carry on after a stray click.\n                  onMouseDown={(e) => {\n                    if (!(e.target instanceof HTMLInputElement))\n                      e.preventDefault();\n                  }}\n                  // Pinned 20vh from the top rather than vertically centered, so\n                  // the input stays put while filtering resizes the list below.\n                  className=\"absolute top-[20vh] left-1/2 flex w-[400px] max-w-[calc(100vw-32px)] origin-top -translate-x-1/2 flex-col overflow-hidden rounded-xl bg-background shadow-raised\"\n                >\n                  <div className=\"flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4 text-muted-foreground\">\n                    <SearchIcon />\n                    <input\n                      autoFocus\n                      type=\"text\"\n                      role=\"combobox\"\n                      aria-expanded=\"true\"\n                      aria-controls={`${id}-listbox`}\n                      aria-autocomplete=\"list\"\n                      aria-activedescendant={\n                        activeItem ? optionId(activeItem.command) : undefined\n                      }\n                      aria-label=\"Search commands\"\n                      placeholder={placeholder}\n                      spellCheck={false}\n                      autoComplete=\"off\"\n                      value={query}\n                      onChange={(e) => {\n                        setQuery(e.target.value);\n                        setActive(0);\n                      }}\n                      onKeyDown={onInputKeyDown}\n                      className=\"h-full min-w-0 flex-1 bg-transparent text-sm text-foreground outline-hidden placeholder:text-muted-foreground\"\n                    />\n                  </div>\n\n                  {/* 4px padding + 8px item radius = the panel's 12px radius. */}\n                  <div\n                    id={`${id}-listbox`}\n                    role=\"listbox\"\n                    aria-label=\"Commands\"\n                    className={cn(\n                      \"max-h-[min(320px,50vh)] scroll-py-1 overflow-y-auto overscroll-contain p-1\",\n                      !flat.length && \"hidden\",\n                    )}\n                    onPointerLeave={() => {\n                      pointer.current = null;\n                    }}\n                  >\n                    {results.map(({ group, items }) => (\n                      <div\n                        key={group}\n                        role=\"group\"\n                        aria-labelledby={`${id}-group-${group}`}\n                      >\n                        <div\n                          id={`${id}-group-${group}`}\n                          className=\"px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground select-none\"\n                        >\n                          {group}\n                        </div>\n                        {items.map(({ command, ranges }) => {\n                          index += 1;\n                          const i = index;\n                          const selected = i === activeIndex;\n                          return (\n                            <div\n                              key={command.id}\n                              id={optionId(command)}\n                              role=\"option\"\n                              aria-selected={selected}\n                              onPointerMove={(e) => onOptionPointerMove(e, i)}\n                              onClick={() => run(command)}\n                              className={cn(\n                                \"flex h-9 cursor-default items-center gap-2.5 rounded-lg px-3 text-sm select-none\",\n                                selected && \"bg-muted\",\n                              )}\n                            >\n                              <span\n                                aria-hidden\n                                className={cn(\n                                  \"flex shrink-0\",\n                                  selected ? \"text-foreground\" : \"text-muted-foreground\",\n                                )}\n                              >\n                                {command.icon}\n                              </span>\n                              <Highlight\n                                label={command.label}\n                                ranges={ranges}\n                              />\n                              {command.shortcut && (\n                                <Kbd keys={command.shortcut} />\n                              )}\n                            </div>\n                          );\n                        })}\n                      </div>\n                    ))}\n                  </div>\n\n                  {!flat.length && (\n                    <p\n                      role=\"status\"\n                      className=\"px-4 py-8 text-center text-sm text-muted-foreground\"\n                    >\n                      No results\n                    </p>\n                  )}\n                </motion.div>\n              </motion.div>\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n\nfunction Highlight({\n  label,\n  ranges,\n}: {\n  label: string;\n  ranges: [number, number][];\n}) {\n  if (!ranges.length)\n    return <span className=\"flex-1 truncate text-foreground\">{label}</span>;\n  const parts: React.ReactNode[] = [];\n  let cursor = 0;\n  for (const [start, end] of ranges) {\n    if (start > cursor) parts.push(label.slice(cursor, start));\n    parts.push(\n      <span key={start} className=\"text-foreground\">\n        {label.slice(start, end)}\n      </span>,\n    );\n    cursor = end;\n  }\n  parts.push(label.slice(cursor));\n  return <span className=\"flex-1 truncate text-muted-foreground\">{parts}</span>;\n}\n\nfunction Kbd({ keys }: { keys: string[] }) {\n  return (\n    <span className=\"flex shrink-0 gap-0.5\" aria-hidden>\n      {keys.map((key, i) => (\n        <kbd\n          key={i}\n          className=\"flex h-5 min-w-5 items-center justify-center rounded border border-border bg-background px-1 font-sans text-[11px] leading-none text-muted-foreground\"\n        >\n          {key}\n        </kbd>\n      ))}\n    </span>\n  );\n}\n\nfunction LineIcon({ children }: { children: React.ReactNode }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      // 1.5 matches the regular-weight labels beside it.\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      {children}\n    </svg>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <LineIcon>\n      <circle cx=\"7\" cy=\"7\" r=\"4.25\" />\n      <path d=\"m10.25 10.25 3 3\" />\n    </LineIcon>\n  );\n}\n\nconst DEMO_COMMANDS: Command[] = [\n  {\n    id: \"home\",\n    group: \"Navigation\",\n    label: \"Go to Home\",\n    shortcut: [\"G\", \"H\"],\n    icon: (\n      <LineIcon>\n        <path d=\"M2.75 7.25 8 2.75l5.25 4.5v6h-3.5v-3.5h-3.5v3.5h-3.5z\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"inbox\",\n    group: \"Navigation\",\n    label: \"Open Inbox\",\n    shortcut: [\"G\", \"I\"],\n    icon: (\n      <LineIcon>\n        <path d=\"M2.75 9.25 4.5 3.25h7l1.75 6v3.5H2.75zM2.75 9.25h3l.75 1.5h3l.75-1.5h3\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"settings\",\n    group: \"Navigation\",\n    label: \"Go to Settings\",\n    shortcut: [\"G\", \"S\"],\n    icon: (\n      <LineIcon>\n        <path d=\"M2.75 4.75h6.5M12.25 4.75h1M2.75 11.25h1M6.75 11.25h6.5\" />\n        <circle cx=\"10.75\" cy=\"4.75\" r=\"1.5\" />\n        <circle cx=\"5.25\" cy=\"11.25\" r=\"1.5\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"docs\",\n    group: \"Navigation\",\n    label: \"Search documentation\",\n    icon: (\n      <LineIcon>\n        <path d=\"M4.25 2.75h5l2.5 2.5v8h-7.5zM9.25 2.75v2.5h2.5M6.25 8.25h3.5M6.25 10.75h2\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"new-file\",\n    group: \"Actions\",\n    label: \"New file\",\n    shortcut: [\"⌘\", \"N\"],\n    icon: (\n      <LineIcon>\n        <path d=\"M8 3.25v9.5M3.25 8h9.5\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"copy-link\",\n    group: \"Actions\",\n    label: \"Copy link\",\n    icon: (\n      <LineIcon>\n        <path d=\"M7 9a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-.75.75M9 7a2.5 2.5 0 0 0-3.5 0l-2 2A2.5 2.5 0 0 0 7 12.5l.75-.75\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"theme\",\n    group: \"Actions\",\n    label: \"Toggle theme\",\n    shortcut: [\"⇧\", \"T\"],\n    icon: (\n      <LineIcon>\n        <circle cx=\"8\" cy=\"8\" r=\"5.25\" />\n        <path d=\"M8 2.75a5.25 5.25 0 0 1 0 10.5z\" fill=\"currentColor\" />\n      </LineIcon>\n    ),\n  },\n  {\n    id: \"logout\",\n    group: \"Actions\",\n    label: \"Log out\",\n    icon: (\n      <LineIcon>\n        <path d=\"M6.75 2.75h-3v10.5h3M10.25 5.25 13 8l-2.75 2.75M13 8H6.25\" />\n      </LineIcon>\n    ),\n  },\n];\n\nexport default function CommandPaletteDemo() {\n  const [ran, setRan] = useState<string | null>(null);\n  return (\n    <div className=\"flex flex-col items-center gap-3\">\n      <CommandPalette\n        commands={DEMO_COMMANDS}\n        onRun={(command) => setRan(command.label)}\n      />\n      {/* Height reserved up front, so the confirmation appearing never\n          shifts the trigger in the centered demo. */}\n      <p className=\"h-5 text-sm text-muted-foreground\" aria-live=\"polite\">\n        {ran && (\n          <>\n            Ran: <span className=\"text-foreground\">{ran}</span>\n          </>\n        )}\n      </p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/command-palette"}