{"slug":"command-palette","name":"Command Palette","description":"⌘K palette with fuzzy filter, spring-animated active row and glass surface.","category":"motion","source_url":"https://beui.saura3h.xyz/r/command-palette/raw","detail_url":"https://beui.saura3h.xyz/r/command-palette","raw_url":"https://beui.saura3h.xyz/r/command-palette/raw","page_url":"https://beui.saura3h.xyz/components/motion/command-palette","dependencies":["lucide-react","motion","react"],"internal":["@/components/motion/command-palette","@/lib/utils"],"files":[{"path":"components/motion/command-palette.tsx","type":"component","content":"\"use client\";\n\nimport { motion } from \"motion/react\";\nimport { Search, type LucideIcon } from \"lucide-react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\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\nconst EASE = [0.16, 1, 0.3, 1] 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 = (v: boolean) => {\n    if (!controlled) setInternalOpen(v);\n    onOpenChange?.(v);\n  };\n\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) {\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]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (open) {\n      setQuery(\"\");\n      setActive(0);\n      requestAnimationFrame(() => inputRef.current?.focus());\n    }\n  }, [open]);\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  useEffect(() => setActive(0), [query]);\n\n  const grouped = useMemo(() => {\n    const map = new Map<string, CommandItem[]>();\n    filtered.forEach((it) => {\n      const g = it.group ?? \"Results\";\n      if (!map.has(g)) map.set(g, []);\n      map.get(g)!.push(it);\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>(`[data-index=\"${active}\"]`);\n    el?.scrollIntoView({ block: \"nearest\" });\n  }, [active, open]);\n\n  let cursor = 0;\n\n  // Always-mounted container; pointer events fully disabled when closed so clicks pass through to the page.\n  return (\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 }}\n        onClick={() => setOpen(false)}\n        className={cn(\n          \"absolute inset-0 bg-black/55 [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 ? 0 : -8,\n            scale: open ? 1 : 0.97,\n          }}\n          transition={\n            open\n              ? { type: \"spring\", stiffness: 460, damping: 36, mass: 0.6 }\n              : { duration: 0.12, ease: EASE }\n          }\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"w-full max-w-xl overflow-hidden rounded-2xl border border-(--color-border-strong) bg-(--color-bg-elev) shadow-[0_24px_60px_-12px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.04)_inset] will-change-transform\",\n            open ? \"pointer-events-auto\" : \"pointer-events-none\",\n          )}\n        >\n          <div className=\"flex items-center gap-3 border-b border-(--color-border) px-4\">\n            <Search className=\"h-4 w-4 text-(--color-fg-muted)\" />\n            <input\n              ref={inputRef}\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              placeholder={placeholder}\n              tabIndex={open ? 0 : -1}\n              className=\"h-12 flex-1 bg-transparent text-sm text-(--color-fg) placeholder:text-(--color-fg-muted) outline-none\"\n            />\n            <kbd className=\"hidden rounded border border-(--color-border) bg-(--color-bg) px-1.5 py-0.5 text-[10px] text-(--color-fg-muted) sm:inline-block\">\n              ESC\n            </kbd>\n          </div>\n          <div ref={listRef} className=\"max-h-[60vh] overflow-y-auto p-2\">\n            {filtered.length === 0 ? (\n              <div className=\"p-8 text-center text-sm text-(--color-fg-muted)\">{emptyMessage}</div>\n            ) : (\n              grouped.map(([group, list]) => (\n                <div key={group} className=\"mb-1 last:mb-0\">\n                  <div className=\"px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-(--color-fg-muted)\">\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                        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 ? \"text-(--color-fg)\" : \"text-(--color-fg-muted)\",\n                        )}\n                      >\n                        {isActive ? (\n                          <motion.span\n                            layoutId=\"command-active\"\n                            className=\"absolute inset-0 z-0 rounded-md bg-(--color-fg)/[0.05]\"\n                            transition={{ type: \"spring\", stiffness: 480, damping: 38 }}\n                          />\n                        ) : null}\n                        {Icon ? <Icon className=\"relative z-10 h-4 w-4\" /> : <span className=\"relative z-10 h-4 w-4\" />}\n                        <span className=\"relative z-10 flex-1 truncate\">{it.label}</span>\n                        {it.hint ? (\n                          <kbd className=\"relative z-10 rounded border border-(--color-border) bg-(--color-bg) px-1.5 py-0.5 text-[10px] text-(--color-fg-muted)\">\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  );\n}\n"},{"path":"components/previews/motion/command-palette.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { FileText, Home, Plus, Settings, User } from \"lucide-react\";\nimport { CommandPalette } from \"@/components/motion/command-palette\";\n\nexport function CommandPalettePreview() {\n  return (\n    <div className=\"flex flex-col items-start gap-3\">\n      <p className=\"text-sm text-(--color-fg-muted)\">\n        Press{\" \"}\n        <kbd className=\"rounded border border-(--color-border) bg-(--color-bg-elev) px-1.5 py-0.5 text-xs text-(--color-fg)\">\n          ⌘ K\n        </kbd>{\" \"}\n        (or <kbd className=\"rounded border border-(--color-border) bg-(--color-bg-elev) px-1.5 py-0.5 text-xs text-(--color-fg)\">Ctrl K</kbd>) to open.\n      </p>\n      <CommandPalette\n        items={[\n          { id: \"home\", label: \"Go to Home\", group: \"Navigation\", icon: Home, hint: \"G H\", onSelect: () => {} },\n          { id: \"profile\", label: \"Open profile\", group: \"Navigation\", icon: User, hint: \"G P\", onSelect: () => {} },\n          { id: \"settings\", label: \"Settings\", group: \"Navigation\", icon: Settings, onSelect: () => {} },\n          { id: \"new-doc\", label: \"Create document\", group: \"Actions\", icon: FileText, hint: \"⌘ N\", onSelect: () => {} },\n          { id: \"new-project\", label: \"New project\", group: \"Actions\", icon: Plus, hint: \"⌘ ⇧ N\", onSelect: () => {} },\n        ]}\n      />\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"util","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"}]}