{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "type": "registry:ui",
  "title": "Command palette",
  "description": "Combines search, keyboard navigation, and deliberate focus management.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "overlays-surfaces"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/command-palette/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Search results and keyboard focus move continuously inside the palette",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Command results with active keyboard focus",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/command-palette.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/command-palette.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst BOUNDARY = /[\\s\\-_/.:]/;\nconst ROW = 36;\nconst GAP = 2;\nconst PAD = 5;\n\nconst useIsoLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type CommandItem = {\n  id: string;\n  label: string;\n  hint?: string;\n  keywords?: string;\n  shortcut?: string[];\n};\n\nexport type UseCommandPaletteOptions = {\n  items: CommandItem[];\n  onSelect: (item: CommandItem) => void;\n  onDismiss?: () => void;\n};\n\nfunction scoreOne(text: string, query: string): number {\n  const t = text.toLowerCase();\n  let cursor = 0;\n  let total = 0;\n  let streak = 0;\n\n  for (let i = 0; i < query.length; i++) {\n    const at = t.indexOf(query[i], cursor);\n    if (at < 0) return -1;\n    streak = at === cursor && i > 0 ? streak + 1 : 0;\n    total += 2 + streak * 4;\n    if (at === 0) total += 12;\n    else if (BOUNDARY.test(t[at - 1])) total += 8;\n    cursor = at + 1;\n  }\n\n  return total;\n}\n\nfunction rank(items: CommandItem[], query: string): CommandItem[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return items;\n\n  const scored: { item: CommandItem; score: number; order: number }[] = [];\n\n  for (let i = 0; i < items.length; i++) {\n    const item = items[i];\n    const direct = scoreOne(item.label, q);\n    const aliased = item.keywords ? scoreOne(item.keywords, q) - 3 : -1;\n    const best = Math.max(direct, item.keywords ? aliased : -1);\n    if (best < 0) continue;\n    scored.push({ item, score: best - item.label.length * 0.05, order: i });\n  }\n\n  scored.sort((a, b) => b.score - a.score || a.order - b.order);\n  return scored.map((s) => s.item);\n}\n\nexport function useCommandPalette({\n  items,\n  onSelect,\n  onDismiss,\n}: UseCommandPaletteOptions) {\n  const [query, setQuery] = useState(\"\");\n  const [pinned, setPinned] = useState<string | null>(null);\n\n  const listRef = useRef<HTMLUListElement>(null);\n  const pointer = useRef({ x: -1, y: -1 });\n\n  const select = useRef(onSelect);\n  select.current = onSelect;\n  const dismiss = useRef(onDismiss);\n  dismiss.current = onDismiss;\n\n  const results = useMemo(() => rank(items, query), [items, query]);\n\n  const activeId = results.some((r) => r.id === pinned)\n    ? pinned\n    : (results[0]?.id ?? null);\n  const activeIndex = results.findIndex((r) => r.id === activeId);\n\n  useEffect(() => {\n    if (listRef.current) listRef.current.scrollTop = 0;\n  }, [query]);\n\n  const reveal = (index: number) => {\n    const list = listRef.current;\n    const row = list?.children[index];\n    if (!list || !(row instanceof HTMLElement)) return;\n    const top = row.offsetTop - PAD;\n    const bottom = row.offsetTop + row.offsetHeight + PAD;\n    if (top < list.scrollTop) list.scrollTop = top;\n    else if (bottom > list.scrollTop + list.clientHeight) {\n      list.scrollTop = bottom - list.clientHeight;\n    }\n  };\n\n  const jump = (index: number) => {\n    if (results.length === 0) return;\n    const next = Math.max(0, Math.min(results.length - 1, index));\n    setPinned(results[next].id);\n    reveal(next);\n  };\n\n  const move = (delta: number) => {\n    if (results.length === 0) return;\n    const from = activeIndex < 0 ? 0 : activeIndex;\n    jump((from + delta + results.length) % results.length);\n  };\n\n  const run = (item?: CommandItem) => {\n    const target = item ?? results.find((r) => r.id === activeId);\n    if (target) select.current(target);\n  };\n\n  const pointerActivate = (id: string, event: React.PointerEvent) => {\n    const { x, y } = pointer.current;\n    if (event.clientX === x && event.clientY === y) return;\n    pointer.current = { x: event.clientX, y: event.clientY };\n    if (id !== activeId) setPinned(id);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      move(1);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      move(-1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      jump(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      jump(results.length - 1);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      run();\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      dismiss.current?.();\n    }\n  };\n\n  return {\n    query,\n    setQuery,\n    results,\n    activeId,\n    activeIndex,\n    listRef,\n    onKeyDown,\n    pointerActivate,\n    jump,\n    move,\n    run,\n  };\n}\n\nexport type CommandPaletteProps = {\n  items: CommandItem[];\n  onSelect: (item: CommandItem) => void;\n  onDismiss?: () => void;\n\n  open?: boolean;\n  placeholder?: string;\n  emptyLabel?: string;\n  label?: string;\n  maxRows?: number;\n  autoFocus?: boolean;\n  className?: string;\n};\n\nexport function CommandPalette({\n  items,\n  onSelect,\n  onDismiss,\n  open,\n  placeholder = \"Search commands\",\n  emptyLabel = \"No command matches\",\n  label = \"Command palette\",\n  maxRows = 6,\n  autoFocus = false,\n  className = \"\",\n}: CommandPaletteProps) {\n  const uid = useId();\n  const reduced = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n\n  const {\n    query,\n    setQuery,\n    results,\n    activeId,\n    listRef,\n    onKeyDown,\n    pointerActivate,\n    run,\n  } = useCommandPalette({ items, onSelect, onDismiss });\n\n  const rows = Math.max(1, Math.min(maxRows, items.length));\n  const height = PAD * 2 + rows * ROW + (rows - 1) * GAP;\n  const count = results.length;\n\n  useEffect(() => {\n    if (autoFocus && open === undefined) {\n      inputRef.current?.focus({ preventScroll: true });\n    }\n  }, [autoFocus, open]);\n\n  useEffect(() => {\n    if (open) setQuery(\"\");\n  }, [open, setQuery]);\n\n  useEffect(() => {\n    const id = setTimeout(() => {\n      if (!liveRef.current) return;\n      liveRef.current.textContent =\n        count === 0\n          ? emptyLabel\n          : `${count} ${count === 1 ? \"command\" : \"commands\"} available`;\n    }, 400);\n    return () => clearTimeout(id);\n  }, [count, emptyLabel]);\n\n  const spring = reduced ? { duration: 0 } : CELL;\n\n  const overlaid = open !== undefined;\n\n  const surface = (\n    <div\n      className={`overflow-hidden rounded-[14px] border border-stone-200 bg-white dark:border-white/[0.16] dark:bg-[#1D1D1A] ${\n        overlaid\n          ? \"w-full max-w-[520px] shadow-[0_1px_2px_rgba(28,25,23,0.07),0_28px_56px_-24px_rgba(24,22,20,0.5)] dark:shadow-[0_3px_16px_rgba(0,0,0,0.65)]\"\n          : \"\"\n      } ${className}`}\n    >\n      <div className=\"flex h-11 items-center gap-2.5 border-b border-stone-200 px-3 dark:border-white/[0.16]\">\n        <svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-[14px] shrink-0 text-stone-500 dark:text-stone-400\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.4\"\n          strokeLinecap=\"round\"\n          aria-hidden\n        >\n          <circle cx=\"7\" cy=\"7\" r=\"4.25\" />\n          <path d=\"M10.2 10.2 13.5 13.5\" />\n        </svg>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={label}\n          aria-expanded\n          aria-controls={`${uid}-list`}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeId ? `${uid}-${activeId}` : undefined}\n          autoComplete=\"off\"\n          spellCheck={false}\n          value={query}\n          placeholder={placeholder}\n          onChange={(e) => setQuery(e.target.value)}\n          onKeyDown={onKeyDown}\n          className=\"h-full min-w-0 flex-1 bg-transparent text-[13.5px] text-stone-700 outline-none placeholder:text-stone-400 dark:text-stone-200 dark:placeholder:text-stone-500\"\n        />\n        <span className=\"min-w-[3ch] shrink-0 text-right font-mono text-[9.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n          {count}\n        </span>\n      </div>\n      <div className=\"relative\" style={{ height }}>\n        <ul\n          ref={listRef}\n          id={`${uid}-list`}\n          role=\"listbox\"\n          aria-label={label}\n          onMouseDown={(e) => e.preventDefault()}\n          className=\"absolute inset-0 flex flex-col gap-[2px] overflow-y-auto overscroll-contain p-[5px] [scrollbar-gutter:stable]\"\n        >\n          {results.map((item) => {\n            const active = item.id === activeId;\n            return (\n              <motion.li\n                key={item.id}\n                id={`${uid}-${item.id}`}\n                role=\"option\"\n                aria-selected={active}\n                layout={reduced ? false : \"position\"}\n                transition={spring}\n                onPointerMove={(e) => pointerActivate(item.id, e)}\n                onClick={() => run(item)}\n                className=\"relative flex h-9 shrink-0 cursor-default items-center rounded-[9px] px-2.5\"\n              >\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={{ opacity: active ? 1 : 0 }}\n                  transition={reduced ? { duration: 0 } : CROSSFADE}\n                  className=\"absolute inset-0 rounded-[9px] bg-stone-100 dark:bg-white/10\"\n                />\n                <span className=\"relative flex min-w-0 flex-1 items-center gap-2.5\">\n                  <span className=\"truncate text-[13px] font-medium text-stone-700 dark:text-stone-200\">\n                    {item.label}\n                  </span>\n\n                  {item.hint ? (\n                    <span className=\"hidden shrink-0 text-[11.5px] text-stone-500 sm:inline dark:text-stone-400\">\n                      {item.hint}\n                    </span>\n                  ) : null}\n\n                  {item.shortcut ? (\n                    <span className=\"ml-auto flex shrink-0 items-center gap-1\">\n                      {item.shortcut.map((key) => (\n                        <span\n                          key={key}\n                          className=\"flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-stone-200 px-1 font-mono text-[9.5px] tabular-nums text-stone-500 dark:border-white/[0.16] dark:text-stone-400\"\n                        >\n                          {key}\n                        </span>\n                      ))}\n                    </span>\n                  ) : null}\n                </span>\n              </motion.li>\n            );\n          })}\n        </ul>\n\n        {count === 0 ? (\n          <motion.p\n            initial={reduced ? false : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={reduced ? { duration: 0 } : CROSSFADE}\n            className=\"pointer-events-none absolute inset-0 flex items-center justify-center px-3 text-center text-[12.5px] text-stone-500 dark:text-stone-400\"\n          >\n            {emptyLabel}\n          </motion.p>\n        ) : null}\n      </div>\n      <span ref={liveRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n\n  if (!overlaid) return surface;\n  return <PaletteLayer open={open} onDismiss={onDismiss} label={label}>{surface}</PaletteLayer>;\n}\n\nfunction PaletteLayer({\n  open,\n  onDismiss,\n  label,\n  children,\n}: {\n  open: boolean;\n  onDismiss?: () => void;\n  label: string;\n  children: React.ReactNode;\n}) {\n  const [host, setHost] = useState<HTMLElement | null>(null);\n  const layerRef = useRef<HTMLDivElement | null>(null);\n  const returnFocus = useRef<HTMLElement | null>(null);\n  const leave = useRef(onDismiss);\n  leave.current = onDismiss;\n\n  useEffect(() => setHost(document.body), []);\n\n  useIsoLayoutEffect(() => {\n    if (!open || !host) return;\n    returnFocus.current = document.activeElement instanceof HTMLElement\n      ? document.activeElement\n      : null;\n    layerRef.current\n      ?.querySelector<HTMLElement>(\n        'input:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex=\"-1\"])',\n      )\n      ?.focus({ preventScroll: true });\n    return () => {\n      const node = returnFocus.current;\n      requestAnimationFrame(() => node?.focus({ preventScroll: true }));\n      returnFocus.current = null;\n    };\n  }, [host, open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        event.stopPropagation();\n        leave.current?.();\n        return;\n      }\n\n      if (event.key !== \"Tab\") return;\n      const focusable = Array.from(\n        layerRef.current?.querySelectorAll<HTMLElement>(\n          'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n        ) ?? [],\n      ).filter((node) => !node.hasAttribute(\"aria-hidden\"));\n      if (focusable.length === 0) {\n        event.preventDefault();\n        return;\n      }\n\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      if (event.shiftKey && (active === first || !layerRef.current?.contains(active))) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && (active === last || !layerRef.current?.contains(active))) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", onKeyDown, true);\n    return () => document.removeEventListener(\"keydown\", onKeyDown, true);\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const root = document.documentElement;\n    const overflow = root.style.overflow;\n    const padding = root.style.paddingRight;\n    const gutter = window.innerWidth - root.clientWidth;\n    root.style.overflow = \"hidden\";\n    if (gutter > 0) root.style.paddingRight = `${gutter}px`;\n    return () => {\n      root.style.overflow = overflow;\n      root.style.paddingRight = padding;\n    };\n  }, [open]);\n\n  if (!host) return null;\n\n  return createPortal(\n    open ? (\n        <div\n          ref={layerRef}\n          role=\"dialog\"\n          aria-modal=\"true\"\n          aria-label={label}\n          className=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n          onPointerDown={(event) => {\n            if (event.target !== event.currentTarget) return;\n            event.preventDefault();\n            leave.current?.();\n          }}\n        >\n          <div\n            aria-hidden\n            className=\"absolute inset-0 bg-stone-900/40 dark:bg-black/65\"\n            onPointerDown={(event) => {\n              event.preventDefault();\n              leave.current?.();\n            }}\n          />\n          <div className=\"relative flex w-full justify-center\">\n            {children}\n          </div>\n        </div>\n      ) : null,\n    host,\n  );\n}\n"
    }
  ]
}
