{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-palette","type":"registry:block","title":"Command Palette","description":"⌘K palette with fuzzy filter, spring-animated active row and glass surface.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/command-palette.tsx","type":"registry:component","target":"@components/motion/command-palette.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Search, type LucideIcon } from \"lucide-react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CommandItem = {\n  id: string;\n  label: string;\n  group?: string;\n  hint?: string;\n  keywords?: string[];\n  icon?: LucideIcon;\n  onSelect: () => void;\n};\n\nexport interface CommandPaletteProps {\n  items: CommandItem[];\n  /** Opens with Cmd/Ctrl + this key. Default: \"k\" */\n  shortcut?: string;\n  placeholder?: string;\n  emptyMessage?: string;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nfunction fuzzyMatch(needle: string, hay: string) {\n  if (!needle) return true;\n  needle = needle.toLowerCase();\n  hay = hay.toLowerCase();\n  let i = 0;\n  for (const ch of hay) {\n    if (ch === needle[i]) i++;\n    if (i === needle.length) return true;\n  }\n  return false;\n}\n\n// Opened via a keyboard shortcut many times a day — entrance must read as\n// instant. Tight spring, even faster exit.\nconst PANEL_SPRING = {\n  type: \"spring\",\n  stiffness: 560,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\nexport function CommandPalette({\n  items,\n  shortcut = \"k\",\n  placeholder = \"Type a command or search…\",\n  emptyMessage = \"No results found.\",\n  open: controlledOpen,\n  onOpenChange,\n}: CommandPaletteProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n  const setOpen = useCallback(\n    (v: boolean) => {\n      if (!controlled) setInternalOpen(v);\n      onOpenChange?.(v);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  // Portal target only exists client-side; render nothing during SSR/hydration.\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const updateQuery = useCallback((value: string) => {\n    setQuery(value);\n    setActive(0);\n  }, []);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (\n        (e.metaKey || e.ctrlKey) &&\n        e.key.toLowerCase() === shortcut.toLowerCase()\n      ) {\n        e.preventDefault();\n        setOpen(!open);\n        return;\n      }\n      if (e.key === \"Escape\" && open) {\n        e.preventDefault();\n        setOpen(false);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open, shortcut, setOpen]);\n\n  useEffect(() => {\n    if (open) {\n      updateQuery(\"\");\n      setActive(0);\n      requestAnimationFrame(() => inputRef.current?.focus());\n    }\n  }, [open, updateQuery]);\n\n  useEffect(() => {\n    if (!open) return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [open]);\n\n  const filtered = useMemo(() => {\n    if (!query) return items;\n    return items.filter((it) => {\n      const haystacks = [it.label, it.group ?? \"\", ...(it.keywords ?? [])];\n      return haystacks.some((h) => fuzzyMatch(query, h));\n    });\n  }, [items, query]);\n\n  // Reserve the icon column only when at least one item brings an icon, so\n  // icon-less lists don't render a dead gap before every label.\n  const hasIcons = useMemo(() => items.some((it) => it.icon), [items]);\n\n  const grouped = useMemo(() => {\n    const map = new Map<string, CommandItem[]>();\n    filtered.forEach((it) => {\n      const g = it.group ?? \"Results\";\n      const groupItems = map.get(g) ?? [];\n      groupItems.push(it);\n      map.set(g, groupItems);\n    });\n    return Array.from(map.entries());\n  }, [filtered]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setActive((a) => Math.min(filtered.length - 1, a + 1));\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActive((a) => Math.max(0, a - 1));\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const it = filtered[active];\n      if (it) {\n        it.onSelect();\n        setOpen(false);\n      }\n    }\n  };\n\n  useEffect(() => {\n    if (!open) return;\n    const el = listRef.current?.querySelector<HTMLButtonElement>(\n      `[data-index=\"${active}\"]`,\n    );\n    el?.scrollIntoView({ block: \"nearest\" });\n  }, [active, open]);\n\n  let cursor = 0;\n\n  if (!mounted) return null;\n\n  // Always-mounted container; pointer events fully disabled when closed so clicks\n  // pass through to the page. Portaled to <body> so ancestors with transforms,\n  // filters, or fixed positioning can't trap the overlay in their stacking context.\n  return createPortal(\n    <div\n      aria-hidden={!open}\n      className={cn(\n        \"fixed inset-0 z-[100]\",\n        open ? \"pointer-events-auto\" : \"pointer-events-none\",\n      )}\n    >\n      <motion.div\n        initial={false}\n        animate={{ opacity: open ? 1 : 0 }}\n        transition={{ duration: open ? 0.18 : 0.12, ease: EASE_OUT }}\n        onClick={() => setOpen(false)}\n        className={cn(\n          \"absolute inset-0 bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]\",\n          open ? \"pointer-events-auto\" : \"pointer-events-none\",\n        )}\n      />\n      <div className=\"pointer-events-none absolute inset-0 flex items-start justify-center p-4 pt-[18vh]\">\n        <motion.div\n          role=\"dialog\"\n          aria-modal=\"true\"\n          aria-label=\"Command palette\"\n          initial={false}\n          animate={{\n            opacity: open ? 1 : 0,\n            y: open || reduce ? 0 : -8,\n            scale: open || reduce ? 1 : 0.97,\n          }}\n          transition={\n            reduce\n              ? { duration: 0.1 }\n              : open\n                ? PANEL_SPRING\n                : { duration: 0.12, ease: EASE_OUT }\n          }\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl will-change-transform\",\n            open ? \"pointer-events-auto\" : \"pointer-events-none\",\n          )}\n        >\n          <div className=\"flex items-center gap-3 border-b border-border px-4\">\n            <Search className=\"h-4 w-4 text-muted-foreground\" />\n            <input\n              ref={inputRef}\n              value={query}\n              onChange={(e) => updateQuery(e.target.value)}\n              placeholder={placeholder}\n              tabIndex={open ? 0 : -1}\n              role=\"combobox\"\n              aria-expanded={open}\n              aria-controls={`${uid}-list`}\n              aria-activedescendant={\n                filtered.length > 0 ? `${uid}-opt-${active}` : undefined\n              }\n              aria-autocomplete=\"list\"\n              className=\"h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none\"\n            />\n            <kbd className=\"hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground sm:inline-block\">\n              ESC\n            </kbd>\n          </div>\n          <div\n            ref={listRef}\n            id={`${uid}-list`}\n            role=\"listbox\"\n            aria-label=\"Commands\"\n            className=\"max-h-[60vh] overflow-y-auto p-2\"\n          >\n            {filtered.length === 0 ? (\n              <div className=\"p-8 text-center text-sm text-muted-foreground\">\n                {emptyMessage}\n              </div>\n            ) : (\n              grouped.map(([group, list]) => (\n                <div key={group} className=\"mb-1 last:mb-0\">\n                  <div\n                    aria-hidden\n                    className=\"px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground\"\n                  >\n                    {group}\n                  </div>\n                  {list.map((it) => {\n                    const idx = cursor++;\n                    const isActive = idx === active;\n                    const Icon = it.icon;\n                    return (\n                      <button\n                        key={it.id}\n                        type=\"button\"\n                        id={`${uid}-opt-${idx}`}\n                        role=\"option\"\n                        aria-selected={isActive}\n                        data-index={idx}\n                        onMouseEnter={() => setActive(idx)}\n                        onClick={() => {\n                          it.onSelect();\n                          setOpen(false);\n                        }}\n                        tabIndex={open ? 0 : -1}\n                        className={cn(\n                          \"relative isolate flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors\",\n                          isActive\n                            ? \"text-foreground\"\n                            : \"text-muted-foreground\",\n                        )}\n                      >\n                        {isActive ? (\n                          <motion.span\n                            layoutId={`${uid}-active`}\n                            className=\"absolute inset-0 z-0 rounded-md bg-primary/[0.05]\"\n                            transition={\n                              reduce\n                                ? { duration: 0 }\n                                : // Tracks rapid arrow-key navigation — keep it tighter\n                                  // than SPRING_LAYOUT so it never lags the active row.\n                                  {\n                                    type: \"spring\",\n                                    stiffness: 480,\n                                    damping: 38,\n                                  }\n                            }\n                          />\n                        ) : null}\n                        {Icon ? (\n                          <Icon className=\"relative z-10 h-4 w-4\" />\n                        ) : hasIcons ? (\n                          <span className=\"relative z-10 h-4 w-4\" />\n                        ) : null}\n                        <span className=\"relative z-10 flex-1 truncate\">\n                          {it.label}\n                        </span>\n                        {it.hint ? (\n                          <kbd className=\"relative z-10 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground\">\n                            {it.hint}\n                          </kbd>\n                        ) : null}\n                      </button>\n                    );\n                  })}\n                </div>\n              ))\n            )}\n          </div>\n        </motion.div>\n      </div>\n    </div>,\n    document.body,\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}