{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-palette","type":"registry:component","title":"Command Palette","description":"A single-level Cmd+K palette: a glass panel with fuzzy search and lit match letters, grouped results with a Recent section, a spring highlight that slides between rows, shortcut chips and a full keyboard model.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/command-palette.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\nexport type CommandIcon =\n  | \"arrow\"\n  | \"search\"\n  | \"plus\"\n  | \"copy\"\n  | \"moon\"\n  | \"sparkles\"\n  | \"home\"\n  | \"settings\"\n  | \"user\"\n  | \"link\"\n  | \"doc\"\n  | \"command\"\n  | \"download\"\n  | \"logout\";\n\nexport type CommandItem = {\n  id?: string;\n  label: string;\n  group?: string;\n  icon?: CommandIcon;\n  /** Keys as an array [\"⌘\", \"N\"] or a space separated string \"⌘ N\". */\n  shortcut?: string | string[];\n  /** Extra words the search also matches (not highlighted). */\n  keywords?: string[];\n  /** Shown first, under \"Recent\", until something else is run. */\n  recent?: boolean;\n  onSelect?: () => void;\n};\n\nconst DEFAULT_ITEMS: CommandItem[] = [\n  { label: \"Create new component\", group: \"Create\", icon: \"plus\", shortcut: [\"⌘\", \"N\"] },\n  { label: \"Search the library\", group: \"Navigate\", icon: \"search\", shortcut: [\"⌘\", \"/\"], recent: true },\n  { label: \"Copy install command\", group: \"Actions\", icon: \"copy\", shortcut: [\"⌘\", \"⇧\", \"C\"], recent: true },\n  { label: \"Toggle dark mode\", group: \"Preferences\", icon: \"moon\", shortcut: [\"⌘\", \"D\"] },\n  { label: \"Open AI prompt\", group: \"Actions\", icon: \"sparkles\", shortcut: [\"⌘\", \"J\"] },\n];\n\nconst THEMES = {\n  dark: {\n    bg: \"rgba(12,13,17,0.94)\",\n    border: \"rgba(255,255,255,0.1)\",\n    text: \"#f3f4f6\",\n    muted: \"rgba(255,255,255,0.55)\",\n    faint: \"rgba(255,255,255,0.34)\",\n    line: \"rgba(255,255,255,0.07)\",\n    row: \"rgba(255,255,255,0.065)\",\n    chip: \"rgba(255,255,255,0.06)\",\n    chipLine: \"rgba(255,255,255,0.11)\",\n    scrim: \"rgba(2,3,5,0.52)\",\n    shadow: \"0 40px 90px -24px rgba(0,0,0,0.85), 0 12px 30px -12px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.07)\",\n    mark: \"var(--cp-accent)\",\n    glow: \"rgba(255,255,255,0.055)\",\n  },\n  light: {\n    bg: \"rgba(255,255,255,0.96)\",\n    border: \"rgba(15,17,22,0.1)\",\n    text: \"#0f1116\",\n    muted: \"rgba(15,17,22,0.58)\",\n    faint: \"rgba(15,17,22,0.4)\",\n    line: \"rgba(15,17,22,0.08)\",\n    row: \"rgba(15,17,22,0.055)\",\n    chip: \"rgba(15,17,22,0.045)\",\n    chipLine: \"rgba(15,17,22,0.13)\",\n    scrim: \"rgba(15,17,22,0.3)\",\n    shadow: \"0 40px 90px -28px rgba(15,17,22,0.35), 0 12px 30px -12px rgba(15,17,22,0.2), inset 0 1px 0 rgba(255,255,255,0.9)\",\n    mark: \"color-mix(in srgb, var(--cp-accent) 72%, #000)\",\n    glow: \"rgba(15,17,22,0.04)\",\n  },\n} as const;\n\nconst CSS = [\n  \".cmdp-scrim{animation:cmdp-fade .2s ease both}\",\n  \".cmdp-scrim[data-state=closing]{animation:cmdp-fade-out .16s ease both}\",\n  \".cmdp-pop{animation:cmdp-in .3s cubic-bezier(.16,1,.3,1) both}\",\n  \".cmdp-scrim[data-state=closing] .cmdp-pop{animation:cmdp-out .16s cubic-bezier(.4,0,1,1) both}\",\n  \".cmdp-row-in{animation:cmdp-row .32s cubic-bezier(.16,1,.3,1) both;animation-delay:calc(var(--i,0)*24ms)}\",\n  \".cmdp-list{transition:height .26s cubic-bezier(.2,.8,.2,1)}\",\n  \".cmdp-hi{transition:transform .3s cubic-bezier(.3,1.25,.4,1),height .22s ease,opacity .15s ease,background-color .14s ease}\",\n  \".cmdp-hi::before{content:'';position:absolute;left:0;top:50%;width:2px;height:14px;margin-top:-7px;border-radius:2px;background:var(--cp-accent);box-shadow:0 0 12px var(--cp-accent)}\",\n  \".cmdp-hi[data-picked]{background-color:color-mix(in srgb,var(--cp-accent) 26%,transparent)!important}\",\n  \".cmdp-head::after{content:'';position:absolute;left:0;right:0;bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--cp-accent),transparent);opacity:0;transition:opacity .25s ease}\",\n  \".cmdp-head:focus-within::after{opacity:.75}\",\n  \".cmdp-sweep{animation:cmdp-sweep .7s cubic-bezier(.3,.7,.2,1) both}\",\n  \".cmdp-glow{opacity:0;transition:opacity .3s ease}\",\n  \".cmdp-panel:hover .cmdp-glow{opacity:1}\",\n  \"@keyframes cmdp-fade{from{opacity:0}to{opacity:1}}\",\n  \"@keyframes cmdp-fade-out{to{opacity:0}}\",\n  \"@keyframes cmdp-in{from{opacity:0;transform:translateY(-12px) scale(.96)}to{opacity:1;transform:none}}\",\n  \"@keyframes cmdp-out{to{opacity:0;transform:translateY(-8px) scale(.98)}}\",\n  \"@keyframes cmdp-row{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}\",\n  \"@keyframes cmdp-sweep{0%{opacity:.9;transform:translateX(-100%)}100%{opacity:0;transform:translateX(100%)}}\",\n  \"@media (prefers-reduced-motion:reduce){.cmdp-pop,.cmdp-row-in,.cmdp-sweep{animation:none!important}.cmdp-scrim,.cmdp-scrim[data-state=closing]{animation-duration:.01s!important}.cmdp-list,.cmdp-hi{transition:none!important}}\",\n].join(\"\\n\");\n\n/* ---------- fuzzy search ---------- */\n\ntype Match = { score: number; idx: number[] };\n\nfunction isBoundary(t: string, i: number) {\n  return i === 0 || \" -_/.\".includes(t.charAt(i - 1));\n}\n\nfunction fuzzy(query: string, text: string): Match | null {\n  const t = text.toLowerCase();\n  const sub = t.indexOf(query);\n  if (sub >= 0) {\n    const idx = Array.from({ length: query.length }, (_, k) => sub + k);\n    return { score: 1000 - sub * 4 + (isBoundary(t, sub) ? 220 : 0) - (t.length - query.length) * 0.6, idx };\n  }\n  const q = query.replace(/\\s+/g, \"\");\n  if (!q) return null;\n  let best: Match | null = null;\n  for (let s = t.indexOf(q.charAt(0)); s >= 0; s = t.indexOf(q.charAt(0), s + 1)) {\n    const idx = [s];\n    let score = isBoundary(t, s) ? 30 : 0;\n    let prev = s;\n    let ok = true;\n    for (let k = 1; k < q.length; k++) {\n      const j = t.indexOf(q.charAt(k), prev + 1);\n      if (j < 0) {\n        ok = false;\n        break;\n      }\n      score += j === prev + 1 ? 18 : -(j - prev - 1) * 1.5;\n      if (isBoundary(t, j)) score += 24;\n      idx.push(j);\n      prev = j;\n    }\n    if (!ok) continue;\n    score -= s * 0.8;\n    if (!best || score > best.score) best = { score, idx };\n  }\n  return best;\n}\n\ntype Norm = {\n  id: string;\n  label: string;\n  group: string;\n  icon: CommandIcon;\n  keys: string[];\n  words: string;\n  recent: boolean;\n  onSelect?: () => void;\n};\ntype Row = { item: Norm; idx: number[]; score: number };\ntype Section = { title: string; rows: Row[] };\n\nfunction normalize(items: ReadonlyArray<string | CommandItem>): Norm[] {\n  const seen = new Set<string>();\n  return items.map((raw, n) => {\n    const it: CommandItem = typeof raw === \"string\" ? { label: raw } : raw;\n    let id = it.id ?? it.label;\n    if (seen.has(id)) id = id + \"#\" + n;\n    seen.add(id);\n    const sc = it.shortcut;\n    const keys = Array.isArray(sc) ? sc : sc ? (sc.includes(\" \") ? sc.split(/\\s+/) : Array.from(sc)) : [];\n    return {\n      id,\n      label: it.label,\n      group: it.group ?? \"Commands\",\n      icon: it.icon ?? \"arrow\",\n      keys,\n      words: (it.keywords ?? []).join(\" \").toLowerCase(),\n      recent: !!it.recent,\n      onSelect: it.onSelect,\n    };\n  });\n}\n\nfunction buildSections(items: Norm[], rawQuery: string, recentIds: string[]): Section[] {\n  const query = rawQuery.trim().toLowerCase();\n  if (!query) {\n    const byId = new Map(items.map((i) => [i.id, i]));\n    const recents = recentIds.map((id) => byId.get(id)).filter((i): i is Norm => !!i);\n    const taken = new Set(recents.map((i) => i.id));\n    const sections: Section[] = [];\n    if (recents.length) sections.push({ title: \"Recent\", rows: recents.map((item) => ({ item, idx: [], score: 0 })) });\n    const order: string[] = [];\n    const map = new Map<string, Row[]>();\n    for (const item of items) {\n      if (taken.has(item.id)) continue;\n      if (!map.has(item.group)) {\n        map.set(item.group, []);\n        order.push(item.group);\n      }\n      map.get(item.group)?.push({ item, idx: [], score: 0 });\n    }\n    for (const g of order) sections.push({ title: g, rows: map.get(g) ?? [] });\n    return sections;\n  }\n  const map = new Map<string, Row[]>();\n  for (const item of items) {\n    const m = fuzzy(query, item.label);\n    let row: Row | null = m ? { item, idx: m.idx, score: m.score } : null;\n    if (!row && item.words) {\n      const w = fuzzy(query, item.words);\n      if (w) row = { item, idx: [], score: w.score - 400 };\n    }\n    if (!row) continue;\n    if (!map.has(item.group)) map.set(item.group, []);\n    map.get(item.group)?.push(row);\n  }\n  const sections: Section[] = [];\n  for (const [title, rows] of map) {\n    rows.sort((a, b) => b.score - a.score);\n    sections.push({ title, rows });\n  }\n  sections.sort((a, b) => (b.rows[0]?.score ?? 0) - (a.rows[0]?.score ?? 0));\n  return sections;\n}\n\n/* ---------- small pieces ---------- */\n\nconst ICON_PATHS: Record<CommandIcon, string[]> = {\n  arrow: [\"M5 12h14\", \"M13 6l6 6-6 6\"],\n  search: [\"M20 20l-3.6-3.6\"],\n  plus: [\"M12 5v14\", \"M5 12h14\"],\n  copy: [\"M5 15V6a2 2 0 0 1 2-2h9\"],\n  moon: [\"M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z\"],\n  sparkles: [\"M11 3l1.8 5.2L18 10l-5.2 1.8L11 17l-1.8-5.2L4 10l5.2-1.8L11 3Z\", \"M19 16v4\", \"M17 18h4\"],\n  home: [\"M4 11l8-7 8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1v-8Z\"],\n  settings: [\"M4 7h9\", \"M19 7h1\", \"M4 17h1\", \"M11 17h9\"],\n  user: [\"M4 20a8 8 0 0 1 16 0\"],\n  link: [\"M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1\", \"M14 10a4 4 0 0 0-5.7 0l-3 3A4 4 0 0 0 11 18.7l1-1\"],\n  doc: [\"M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z\", \"M14 3v5h5\", \"M9 13h6\", \"M9 17h6\"],\n  command: [\"M9 9V6a3 3 0 1 0-3 3h3Zm0 0h6m-6 0v6m6-6V6a3 3 0 1 1 3 3h-3Zm0 0v6m0 0h3a3 3 0 1 1-3 3v-3Zm0 0H9m0 0v3a3 3 0 1 1-3-3h3Z\"],\n  download: [\"M12 4v11\", \"M7 11l5 5 5-5\", \"M5 20h14\"],\n  logout: [\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\", \"M16 17l5-5-5-5\", \"M21 12H9\"],\n};\n\nfunction Glyph({ name, size = 16 }: { name: CommandIcon; size?: number }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.7}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      className=\"shrink-0\"\n    >\n      {name === \"search\" && <circle cx=\"11\" cy=\"11\" r=\"7\" />}\n      {name === \"copy\" && <rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" />}\n      {name === \"settings\" && (\n        <>\n          <circle cx=\"16\" cy=\"7\" r=\"2.2\" />\n          <circle cx=\"8\" cy=\"17\" r=\"2.2\" />\n        </>\n      )}\n      {name === \"user\" && <circle cx=\"12\" cy=\"8\" r=\"4\" />}\n      {ICON_PATHS[name].map((d) => (\n        <path key={d} d={d} />\n      ))}\n    </svg>\n  );\n}\n\nfunction Kbd({ children, theme, mac }: { children: string; theme: (typeof THEMES)[\"dark\"] | (typeof THEMES)[\"light\"]; mac: boolean }) {\n  return (\n    <kbd\n      className=\"inline-flex h-[20px] min-w-[20px] items-center justify-center rounded-[6px] border px-1 text-[11px] font-medium leading-none\"\n      style={{\n        fontFamily: \"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\",\n        background: theme.chip,\n        borderColor: theme.chipLine,\n        color: theme.muted,\n      }}\n    >\n      {!mac && children === \"⌘\" ? \"Ctrl\" : children}\n    </kbd>\n  );\n}\n\nfunction Label({ text, idx }: { text: string; idx: number[] }) {\n  if (!idx.length) return <>{text}</>;\n  const on = new Set(idx);\n  const out: Array<{ s: string; m: boolean }> = [];\n  for (let i = 0; i < text.length; i++) {\n    const m = on.has(i);\n    const last = out[out.length - 1];\n    if (last && last.m === m) last.s += text.charAt(i);\n    else out.push({ s: text.charAt(i), m });\n  }\n  return (\n    <>\n      {out.map((r, i) =>\n        r.m ? (\n          <span key={i} style={{ color: \"var(--cp-mark)\", fontWeight: 600 }}>\n            {r.s}\n          </span>\n        ) : (\n          <span key={i}>{r.s}</span>\n        ),\n      )}\n    </>\n  );\n}\n\nconst subscribeNone = () => () => {};\nconst getIsMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);\nconst getServerMac = () => true;\nconst LIST_CAP = 340;\nconst HEX = /^#[0-9a-f]{3,8}$/i;\n\ntype Phase = \"closed\" | \"open\" | \"closing\";\n\nexport function CommandPalette({\n  items = DEFAULT_ITEMS,\n  placeholder = \"Type a command...\",\n  defaultOpen = false,\n  hotkey = true,\n  contained = false,\n  theme = \"dark\",\n  accent = \"#7b93ff\",\n  triggerLabel = \"Search commands\",\n  onSelect,\n}: {\n  items?: ReadonlyArray<string | CommandItem>;\n  placeholder?: string;\n  /** Mount with the palette already open (no autofocus). */\n  defaultOpen?: boolean;\n  /** true: global ⌘K / Ctrl+K. \"local\": only while the pointer or focus is on the trigger or palette. false: off. */\n  hotkey?: boolean | \"local\";\n  /** Anchor the overlay to the nearest positioned ancestor instead of the viewport. */\n  contained?: boolean;\n  theme?: \"dark\" | \"light\";\n  accent?: string;\n  triggerLabel?: string;\n  onSelect?: (item: { id: string; label: string }) => void;\n}) {\n  const uid = useId();\n  const listId = uid + \"-list\";\n  const mac = useSyncExternalStore(subscribeNone, getIsMac, getServerMac);\n  const t = THEMES[theme === \"light\" ? \"light\" : \"dark\"];\n  const accentColor = HEX.test(accent) ? accent : \"#7b93ff\";\n\n  const [phase, setPhase] = useState<Phase>(defaultOpen ? \"open\" : \"closed\");\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const [picked, setPicked] = useState(false);\n  const [note, setNote] = useState(\"\");\n  const [recentIds, setRecentIds] = useState<string[]>(() =>\n    normalize(items)\n      .filter((i) => i.recent)\n      .slice(0, 3)\n      .map((i) => i.id),\n  );\n\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const hiRef = useRef<HTMLDivElement>(null);\n  const phaseRef = useRef<Phase>(phase);\n  const focusOnOpen = useRef(false);\n  const kbdNav = useRef(false);\n  const placed = useRef(false);\n  const pickTimer = useRef(0);\n  const pendingRecent = useRef(\"\");\n\n  const norm = useMemo(() => normalize(items), [items]);\n  const sections = useMemo(() => buildSections(norm, query, recentIds), [norm, query, recentIds]);\n  const flat = useMemo(() => sections.flatMap((s) => s.rows), [sections]);\n  const sig = useMemo(() => flat.map((r) => r.item.id).join(\"|\"), [flat]);\n  const activeIdx = Math.min(active, Math.max(flat.length - 1, 0));\n  const activeRow = flat[activeIdx];\n  const isOpen = phase !== \"closed\";\n  const hasQuery = query.trim().length > 0;\n\n  useEffect(() => {\n    phaseRef.current = phase;\n  }, [phase]);\n\n  const openPalette = useCallback(() => {\n    window.clearTimeout(pickTimer.current);\n    focusOnOpen.current = true;\n    const ran = pendingRecent.current;\n    if (ran) {\n      pendingRecent.current = \"\";\n      setRecentIds((r) => [ran, ...r.filter((x) => x !== ran)].slice(0, 3));\n    }\n    setQuery(\"\");\n    setActive(0);\n    setPicked(false);\n    setPhase(\"open\");\n  }, []);\n\n  const closePalette = useCallback(() => {\n    window.clearTimeout(pickTimer.current);\n    const within = panelRef.current?.contains(document.activeElement);\n    setPhase(\"closing\");\n    if (within) triggerRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  useEffect(() => {\n    if (!hotkey) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key.toLowerCase() !== \"k\" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;\n      if (hotkey === \"local\") {\n        const root = rootRef.current;\n        if (!root || !(root.contains(document.activeElement) || root.matches(\":hover\"))) return;\n      }\n      e.preventDefault();\n      if (hotkey === \"local\") e.stopImmediatePropagation();\n      if (phaseRef.current === \"open\") closePalette();\n      else openPalette();\n    };\n    document.addEventListener(\"keydown\", onKey, true);\n    return () => document.removeEventListener(\"keydown\", onKey, true);\n  }, [hotkey, openPalette, closePalette]);\n\n  useEffect(() => {\n    if (phase !== \"closing\") return;\n    const id = window.setTimeout(() => setPhase(\"closed\"), 170);\n    return () => window.clearTimeout(id);\n  }, [phase]);\n\n  useEffect(() => {\n    if (phase === \"open\" && focusOnOpen.current) {\n      focusOnOpen.current = false;\n      inputRef.current?.focus({ preventScroll: true });\n    }\n  }, [phase]);\n\n  useEffect(() => {\n    if (contained || phase === \"closed\") return;\n    const prev = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = prev;\n    };\n  }, [contained, phase]);\n\n  useEffect(() => () => window.clearTimeout(pickTimer.current), []);\n\n  // Slide the highlight to the active row; keep it in view for keyboard travel.\n  useEffect(() => {\n    const hi = hiRef.current;\n    const inner = innerRef.current;\n    const list = listRef.current;\n    if (!isOpen) {\n      placed.current = false;\n      return;\n    }\n    if (!hi || !inner || !list) return;\n    const row = inner.querySelector<HTMLElement>('[data-idx=\"' + activeIdx + '\"]');\n    if (!row) {\n      hi.style.opacity = \"0\";\n      return;\n    }\n    hi.style.opacity = \"1\";\n    hi.style.height = row.offsetHeight + \"px\";\n    if (!placed.current) hi.style.transition = \"none\";\n    hi.style.transform = \"translateY(\" + row.offsetTop + \"px)\";\n    if (!placed.current) {\n      void hi.offsetHeight;\n      hi.style.transition = \"\";\n      placed.current = true;\n    }\n    if (kbdNav.current) {\n      kbdNav.current = false;\n      const top = row.offsetTop - 8;\n      const bottom = row.offsetTop + row.offsetHeight + 8;\n      if (top < list.scrollTop) list.scrollTop = top;\n      else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;\n    }\n  }, [activeIdx, sig, isOpen]);\n\n  // Ease the list height as results come and go.\n  useEffect(() => {\n    if (!isOpen) return;\n    const list = listRef.current;\n    const inner = innerRef.current;\n    if (!list || !inner || typeof ResizeObserver === \"undefined\") return;\n    const fit = () => {\n      list.style.height = Math.min(inner.offsetHeight, LIST_CAP) + \"px\";\n    };\n    fit();\n    const ro = new ResizeObserver(fit);\n    ro.observe(inner);\n    return () => ro.disconnect();\n  }, [isOpen]);\n\n  const run = (row: Row | undefined) => {\n    if (!row || picked) return;\n    const { item } = row;\n    setPicked(true);\n    setNote(\"Ran \" + item.label);\n    item.onSelect?.();\n    onSelect?.({ id: item.id, label: item.label });\n    window.clearTimeout(pickTimer.current);\n    pickTimer.current = window.setTimeout(() => {\n      setPicked(false);\n      pendingRecent.current = item.id;\n      closePalette();\n    }, 150);\n  };\n\n  const move = (to: number) => {\n    if (!flat.length) return;\n    kbdNav.current = true;\n    setActive(((to % flat.length) + flat.length) % flat.length);\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    const k = e.key;\n    if (k === \"Escape\") {\n      e.preventDefault();\n      closePalette();\n    } else if (k === \"ArrowDown\" || (e.ctrlKey && (k === \"n\" || k === \"j\"))) {\n      e.preventDefault();\n      move(activeIdx + 1);\n    } else if (k === \"ArrowUp\" || (e.ctrlKey && (k === \"p\" || k === \"k\"))) {\n      e.preventDefault();\n      move(activeIdx - 1);\n    } else if (k === \"PageDown\") {\n      e.preventDefault();\n      move(Math.min(activeIdx + 5, flat.length - 1));\n    } else if (k === \"PageUp\") {\n      e.preventDefault();\n      move(Math.max(activeIdx - 5, 0));\n    } else if (k === \"Enter\") {\n      if (e.nativeEvent.isComposing) return;\n      e.preventDefault();\n      run(activeRow);\n    } else if (k === \"Tab\") {\n      e.preventDefault();\n    }\n  };\n\n  const onPanelMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = e.currentTarget;\n    const r = el.getBoundingClientRect();\n    if (!r.width) return;\n    const k = el.offsetWidth / r.width;\n    el.style.setProperty(\"--mx\", (e.clientX - r.left) * k + \"px\");\n    el.style.setProperty(\"--my\", (e.clientY - r.top) * k + \"px\");\n  };\n\n  const vars = {\n    \"--cp-accent\": accentColor,\n    \"--cp-mark\": t.mark,\n  } as CSSProperties;\n\n  let counter = 0;\n  const total = flat.length;\n\n  return (\n    <span ref={rootRef} style={{ display: \"contents\" }}>\n      <style>{CSS}</style>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        onClick={openPalette}\n        aria-haspopup=\"dialog\"\n        aria-expanded={isOpen}\n        aria-keyshortcuts={hotkey ? \"Meta+K Control+K\" : undefined}\n        className=\"inline-flex items-center gap-3 rounded-full border px-4 py-2.5 text-sm transition-[transform,background-color,border-color,opacity] duration-150 hover:opacity-100 active:scale-[0.97] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-current motion-reduce:transition-none\"\n        style={{\n          borderColor: \"color-mix(in srgb, currentColor 24%, transparent)\",\n          background: \"color-mix(in srgb, currentColor 4%, transparent)\",\n          opacity: 0.82,\n        }}\n      >\n        <Glyph name=\"search\" size={15} />\n        {triggerLabel}\n        <kbd\n          className=\"rounded-md border px-1.5 py-0.5 text-[10px] leading-none\"\n          style={{\n            fontFamily: \"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\",\n            borderColor: \"color-mix(in srgb, currentColor 24%, transparent)\",\n            background: \"color-mix(in srgb, currentColor 7%, transparent)\",\n          }}\n        >\n          {mac ? \"⌘K\" : \"Ctrl K\"}\n        </kbd>\n      </button>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {note}\n      </span>\n\n      {isOpen && (\n        <div\n          className=\"cmdp-scrim\"\n          data-state={phase}\n          onPointerDown={(e) => {\n            if (e.target === e.currentTarget) closePalette();\n          }}\n          style={{\n            ...vars,\n            position: contained ? \"absolute\" : \"fixed\",\n            inset: 0,\n            zIndex: contained ? 20 : 2147483000,\n            display: \"flex\",\n            justifyContent: \"center\",\n            alignItems: \"flex-start\",\n            padding: (contained ? \"48px\" : \"clamp(16px, 14vh, 140px)\") + \" 12px 12px\",\n            background: t.scrim,\n            backdropFilter: \"blur(3px)\",\n            WebkitBackdropFilter: \"blur(3px)\",\n            color: t.text,\n            fontFamily: \"inherit\",\n          }}\n        >\n          <div\n            ref={panelRef}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label=\"Command palette\"\n            className=\"cmdp-pop cmdp-panel relative flex w-full flex-col overflow-hidden rounded-[18px] border\"\n            onKeyDown={onKeyDown}\n            onPointerMove={onPanelMove}\n            onPointerDown={(e) => {\n              const el = e.target as HTMLElement;\n              if (!el.closest(\"input\")) {\n                e.preventDefault();\n                inputRef.current?.focus({ preventScroll: true });\n              }\n            }}\n            style={{\n              maxWidth: 560,\n              maxHeight: \"100%\",\n              background: t.bg,\n              borderColor: t.border,\n              boxShadow: t.shadow,\n              backdropFilter: \"blur(24px) saturate(1.4)\",\n              WebkitBackdropFilter: \"blur(24px) saturate(1.4)\",\n            }}\n          >\n            <div\n              aria-hidden=\"true\"\n              className=\"cmdp-glow pointer-events-none absolute inset-0 z-0\"\n              style={{ background: \"radial-gradient(340px circle at var(--mx,50%) var(--my,0%), \" + t.glow + \", transparent 70%)\" }}\n            />\n            <div className=\"cmdp-head relative z-[1] flex shrink-0 items-center gap-3 border-b px-4\" style={{ borderColor: t.line, height: 54 }}>\n              <span style={{ color: t.faint }}>\n                <Glyph name=\"search\" size={18} />\n              </span>\n              <input\n                ref={inputRef}\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value);\n                  setActive(0);\n                  if (listRef.current) listRef.current.scrollTop = 0;\n                }}\n                placeholder={placeholder}\n                role=\"combobox\"\n                aria-expanded=\"true\"\n                aria-controls={listId}\n                aria-autocomplete=\"list\"\n                aria-activedescendant={activeRow ? uid + \"-o-\" + activeIdx : undefined}\n                aria-label=\"Search commands\"\n                autoComplete=\"off\"\n                autoCorrect=\"off\"\n                autoCapitalize=\"off\"\n                spellCheck={false}\n                enterKeyHint=\"go\"\n                className=\"cmdp-in h-full min-w-0 flex-1 bg-transparent text-[15px]\"\n                style={{ outline: \"none\", border: 0, color: t.text, caretColor: accentColor }}\n              />\n              <style>{\".cmdp-in::placeholder{color:\" + t.faint + \"}\"}</style>\n              {hasQuery ? (\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-label=\"Clear search\"\n                  onClick={() => {\n                    setQuery(\"\");\n                    setActive(0);\n                    inputRef.current?.focus({ preventScroll: true });\n                  }}\n                  className=\"grid h-7 w-7 shrink-0 place-items-center rounded-full transition-colors\"\n                  style={{ background: t.chip, color: t.muted }}\n                >\n                  <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" aria-hidden=\"true\">\n                    <path d=\"M6 6l12 12M18 6L6 18\" />\n                  </svg>\n                </button>\n              ) : (\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-label=\"Close command palette\"\n                  onClick={closePalette}\n                  className=\"shrink-0 rounded-[6px] border px-1.5 py-1 text-[10px] font-medium uppercase leading-none tracking-wider\"\n                  style={{ borderColor: t.chipLine, background: t.chip, color: t.faint }}\n                >\n                  esc\n                </button>\n              )}\n              <span\n                key={query.length}\n                aria-hidden=\"true\"\n                className={hasQuery ? \"cmdp-sweep pointer-events-none absolute -bottom-px left-0 h-px w-full\" : \"hidden\"}\n                style={{ background: \"linear-gradient(90deg, transparent, \" + accentColor + \", transparent)\" }}\n              />\n            </div>\n\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-label=\"Commands\"\n              className=\"cmdp-list relative z-[1] min-h-0 shrink overflow-y-auto overscroll-contain\"\n              style={{ maxHeight: LIST_CAP, scrollbarWidth: \"thin\", scrollbarColor: t.chipLine + \" transparent\" }}\n            >\n              <div ref={innerRef} className=\"relative\" style={{ padding: 6 }}>\n                <div\n                  ref={hiRef}\n                  aria-hidden=\"true\"\n                  className=\"cmdp-hi pointer-events-none absolute z-0 rounded-[11px]\"\n                  data-picked={picked ? \"\" : undefined}\n                  style={{\n                    left: 6,\n                    right: 6,\n                    top: 0,\n                    height: 0,\n                    opacity: 0,\n                    background: t.row,\n                    boxShadow: \"inset 0 0 0 1px \" + t.line,\n                  }}\n                />\n                {total === 0 && (\n                  <div className=\"relative z-[1] flex flex-col items-center gap-2 px-6 py-9 text-center\">\n                    <span\n                      className=\"grid h-10 w-10 place-items-center rounded-xl border\"\n                      style={{ borderColor: t.chipLine, background: t.chip, color: t.faint }}\n                    >\n                      <Glyph name=\"search\" size={18} />\n                    </span>\n                    <p className=\"text-sm font-medium\" style={{ color: t.text }}>\n                      No commands match “{query.trim()}”\n                    </p>\n                    <p className=\"text-xs\" style={{ color: t.faint }}>\n                      Check the spelling or try a shorter search.\n                    </p>\n                  </div>\n                )}\n                {sections.map((sec) => (\n                  <div key={sec.title} role=\"group\" aria-label={sec.title}>\n                    <div className=\"px-2.5 pb-1 pt-2.5 text-[11px] font-medium tracking-[0.04em]\" style={{ color: t.faint }} aria-hidden=\"true\">\n                      {sec.title}\n                    </div>\n                    {sec.rows.map((row) => {\n                      const i = counter++;\n                      const on = i === activeIdx;\n                      return (\n                        <div\n                          key={row.item.id}\n                          id={uid + \"-o-\" + i}\n                          role=\"option\"\n                          aria-selected={on}\n                          data-idx={i}\n                          className=\"cmdp-row-in relative z-[1] flex min-h-[42px] cursor-pointer select-none items-center gap-3 rounded-[11px] px-2.5 py-1.5 text-[14px] transition-transform duration-100 active:scale-[0.985] motion-reduce:transition-none\"\n                          style={{ \"--i\": Math.min(i, 9), color: t.text } as CSSProperties}\n                          onPointerMove={() => {\n                            if (i !== activeIdx) setActive(i);\n                          }}\n                          onPointerDown={(e) => {\n                            if (e.pointerType !== \"mouse\" && i !== activeIdx) setActive(i);\n                          }}\n                          onClick={() => run(row)}\n                        >\n                          <span\n                            className=\"grid h-7 w-7 shrink-0 place-items-center rounded-[9px] border transition-colors duration-200\"\n                            style={{\n                              borderColor: on ? \"color-mix(in srgb, var(--cp-accent) 40%, transparent)\" : t.chipLine,\n                              background: on ? \"color-mix(in srgb, var(--cp-accent) 16%, transparent)\" : t.chip,\n                              color: on ? \"var(--cp-mark)\" : t.muted,\n                            }}\n                          >\n                            <Glyph name={row.item.icon} size={15} />\n                          </span>\n                          <span className=\"min-w-0 flex-1 truncate\" style={{ color: on ? t.text : \"color-mix(in srgb, \" + t.text + \" 88%, transparent)\" }}>\n                            <Label text={row.item.label} idx={row.idx} />\n                          </span>\n                          {row.item.keys.length > 0 ? (\n                            <span className=\"flex shrink-0 items-center gap-1\">\n                              {row.item.keys.map((k, n) => (\n                                <Kbd key={n} theme={t} mac={mac}>\n                                  {k}\n                                </Kbd>\n                              ))}\n                            </span>\n                          ) : (\n                            on && (\n                              <span className=\"shrink-0\">\n                                <Kbd theme={t} mac={mac}>\n                                  ↵\n                                </Kbd>\n                              </span>\n                            )\n                          )}\n                        </div>\n                      );\n                    })}\n                  </div>\n                ))}\n              </div>\n            </div>\n\n            <div\n              className=\"relative z-[1] flex shrink-0 items-center justify-between gap-3 border-t px-4 text-[11px]\"\n              style={{ borderColor: t.line, height: 38, color: t.faint }}\n            >\n              <span className=\"flex items-center gap-3\" aria-hidden=\"true\">\n                <span className=\"flex items-center gap-1.5\">\n                  <Kbd theme={t} mac={mac}>↑</Kbd>\n                  <Kbd theme={t} mac={mac}>↓</Kbd>\n                  <span>Move</span>\n                </span>\n                <span className=\"flex items-center gap-1.5\">\n                  <Kbd theme={t} mac={mac}>↵</Kbd>\n                  <span>Run</span>\n                </span>\n              </span>\n              <span aria-live=\"polite\" role=\"status\">\n                {total} {hasQuery ? (total === 1 ? \"result\" : \"results\") : total === 1 ? \"command\" : \"commands\"}\n              </span>\n            </div>\n          </div>\n        </div>\n      )}\n    </span>\n  );\n}\n","type":"registry:component","target":"components/viberdy/command-palette.tsx"}],"meta":{"tier":"free","tags":["command","palette","search","modal","cmdk","fuzzy","keyboard","dialog"],"href":"https://viberdy.dev/library/command-palette","aiPrompt":"Create a dependency-free React + Tailwind \"command palette\" (Cmd+K menu) component named CommandPalette, a \"use client\" single file. React only: no framer-motion, no icon library (draw icons as inline SVG).\n\nProps (all optional, keep these defaults): items (array of strings or { id?, label, group?, icon?, shortcut?, keywords?, recent?, onSelect? }; default five commands: \"Create new component\", \"Search the library\", \"Copy install command\", \"Toggle dark mode\", \"Open AI prompt\"), placeholder = \"Type a command...\", defaultOpen = false, hotkey = true (true = global Cmd/Ctrl+K toggle, \"local\" = only while pointer or focus is on it, false = off), contained = false (overlay is absolute inside the nearest positioned ancestor instead of fixed to the viewport), theme = \"dark\" | \"light\", accent = \"#7b93ff\" (validate as hex), triggerLabel = \"Search commands\", onSelect({ id, label }).\n\nTrigger: a rounded-full pill with a search icon, the label and a small kbd chip showing \"⌘K\" on Apple platforms and \"Ctrl K\" elsewhere (useSyncExternalStore, no hydration mismatch). It inherits currentColor: 24% currentColor border, 4% fill, 82% opacity, presses to scale .97, focus ring is outline-current offset 2px. So it works on light and dark pages.\n\nPanel (paints its own surface): max-width 560px, radius 18px, 1px border, blurred near-black glass (dark: rgba(12,13,17,.94) with a 1px white inset highlight and a deep drop shadow; light: white .96). Overlay scrim rgba(2,3,5,.52) with 3px blur, panel anchored near the top (fixed: clamp(16px,14vh,140px); contained: 48px). Panel enters with a 300ms cubic-bezier(.16,1,.3,1) from translateY(-12px) scale(.96) and leaves in 160ms. A soft 340px radial light follows the pointer across the panel (set --mx/--my in layout pixels), visible on hover.\n\nHeader: 54px, search icon, input (15px), and on the right an \"esc\" button (closes, for touch) or a clear-X once there is text; a 1px accent line fades in under the header on focus and a sweep runs along it on each keystroke.\n\nSearch: trim and lowercase; a contiguous match scores 1000 minus position*4 plus 220 at a word start; otherwise a subsequence match scores +18 consecutive, -1.5 per gap, +24 word-start, +30 start boundary, minus start*0.8, trying every start position. Keywords match at -400. Matched letters are drawn in the accent colour at weight 600. Empty query: a Recent group (items flagged recent, then anything run, newest first, max 3) followed by groups in item order. With a query: groups ordered by their best score, rows by score. Empty state: icon tile, \"No commands match “q”\", a hint line.\n\nRows: 42px minimum, radius 11px, a 28px icon tile (accent-tinted when active), label (truncate), shortcut kbd chips on the right (⌘ shows as Ctrl off Apple) or an ↵ chip on the active row without a shortcut. One highlight element (translateY to the active row, 300ms cubic-bezier(.3,1.25,.4,1), a 2px accent bar with glow on its left, 6.5% white fill) slides between rows; rows stagger in (24ms steps, 320ms). The list height eases (260ms) as results change, capped at 340px, with ResizeObserver. Pointer move activates a row, a tap activates and runs it; running flashes the highlight with the accent for 150ms, then closes. Footer: 38px with ↑↓ Move / ↵ Run chips and a result count.\n\nKeyboard and a11y: panel role=\"dialog\" aria-modal; input role=\"combobox\" with aria-controls, aria-activedescendant and aria-autocomplete=\"list\"; listbox/option roles. ArrowUp/Down, Ctrl+N/P and Ctrl+J/K move (wrapping), PageUp/Down jump 5, Enter runs, Escape closes and returns focus to the trigger, Tab stays inside. Focus the input only when the user opens it (not for defaultOpen). Lock body scroll while open unless contained. Polite live regions announce the result count and \"Ran <label>\". Respect prefers-reduced-motion by removing all animations and transitions.\n\nInject CSS with a plain <style> element placed beside the button. Borders are set inline. Output one self-contained component.","license":"https://viberdy.dev/license"}}