{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "type": "registry:block",
  "title": "Command palette",
  "description": "A complete keyboard driven action surface with search, grouped results, and shortcuts.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/command-palette/command-palette.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, ValueAnimationTransition, Variants } from \"motion/react\";\nimport { Command as CommandIcon, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface CommandItem {\n  id: string;\n  label: string;\n  description?: string;\n  group?: string;\n  keywords?: string[];\n  icon?: ReactNode;\n  shortcut?: string;\n}\nexport interface CommandPaletteProps {\n  items: CommandItem[];\n  placeholder?: string;\n  onSelect?: (item: CommandItem) => void;\n  onClose?: () => void;\n  label?: string;\n  /** Focus the search field on mount, for a palette that opens on demand. */ autoFocus?: boolean;\n}\n\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst leave: Transition = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };\nconst GLIDE_ROWS = 6;\n/** Rows fade out while the rest glide into their place; when the list snaps they leave at once so nothing overlaps the new rows. */\n/** Keyboard hints: the ⌘ K badge and per-result shortcuts. They hide on narrow screens. */\nconst key =\n  \"inline-flex flex-none items-center justify-center border [font-family:inherit] text-xs leading-none font-medium text-ink-3 tabular-nums max-[480px]:hidden\";\n/** Small square buttons in the search row that light up under the pointer. */\nconst rowButton =\n  \"flex-none cursor-pointer place-items-center rounded-button text-ink-3 transition-[color,background-color] duration-fast ease-standard hover:bg-surface hover:text-ink active:bg-surface active:text-ink motion-reduce:transition-none\";\nconst rowExit: Variants = { exit: (glide: boolean) => ({ opacity: 0, transition: glide ? leave : { duration: 0 } }) };\n\nexport function CommandPalette({\n  items,\n  placeholder = \"Search commands\",\n  onSelect,\n  onClose,\n  label = \"Command palette\",\n  autoFocus = false,\n}: CommandPaletteProps) {\n  const inputId = useId();\n  const [query, setQuery] = useState(\"\");\n  const [activeIndex, setActiveIndex] = useState<number | null>(null);\n  const [listHeight, setListHeight] = useState<number | \"auto\">(\"auto\");\n  const reduced = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const pointer = useRef(false);\n  const lastPointer = useRef({ x: -1, y: -1 });\n  const highlightShown = useRef(false);\n  const highlightY = useMotionValue(0);\n  const highlightHeight = useMotionValue(0);\n  const highlightOpacity = useMotionValue(0);\n  const filtered = useMemo(() => {\n    const normalized = query.trim().toLowerCase();\n    if (!normalized) return items;\n    return items.filter((item) =>\n      [item.label, item.description, item.group, ...(item.keywords ?? [])]\n        .filter(Boolean)\n        .join(\" \")\n        .toLowerCase()\n        .includes(normalized),\n    );\n  }, [items, query]);\n  const groupedItems = useMemo(() => {\n    const grouped = new Map<string, Array<{ item: CommandItem; index: number }>>();\n    filtered.forEach((item, index) => {\n      const group = item.group ?? \"Actions\";\n      const entries = grouped.get(group) ?? [];\n      entries.push({ item, index });\n      grouped.set(group, entries);\n    });\n    return [...grouped.entries()];\n  }, [filtered]);\n  // When every remaining row moves within about a viewport, the list closes its gaps with a glide. A long jump (the first letters typed into a long list) snaps like a search result list instead of streaking rows through the frame; only the frame height morphs.\n  const [shift, setShift] = useState({ list: filtered, glide: true });\n  if (shift.list !== filtered) {\n    const before = new Map(shift.list.map((item, index) => [item.id, index]));\n    setShift({\n      list: filtered,\n      glide: filtered.every((item, index) => Math.abs((before.get(item.id) ?? index) - index) <= GLIDE_ROWS),\n    });\n  }\n  const glide = shift.glide && !reduced;\n  const safeActiveIndex = activeIndex === null ? -1 : Math.min(activeIndex, Math.max(filtered.length - 1, 0));\n  const activeId = filtered[safeActiveIndex]?.id;\n\n  useEffect(() => {\n    if (autoFocus) inputRef.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n\n  useEffect(() => {\n    const focusShortcut = (event: KeyboardEvent) => {\n      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"k\") {\n        event.preventDefault();\n        inputRef.current?.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", focusShortcut);\n    return () => document.removeEventListener(\"keydown\", focusShortcut);\n  }, []);\n\n  // The results frame follows the list height, so filtering closes or opens the gap instead of snapping.\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setListHeight(Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? list.offsetHeight)),\n    );\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, []);\n\n  // One highlight follows the active result: a spring for the pointer, a 70ms slide for arrow keys, a fade when it first appears.\n  useLayoutEffect(() => {\n    const node = activeId ? document.getElementById(`${inputId}-${activeId}`) : null;\n    if (!node) {\n      highlightShown.current = false;\n      animate(highlightOpacity, 0, { duration: reduced ? 0 : 0.1 });\n      return;\n    }\n    const move: ValueAnimationTransition<number> =\n      !highlightShown.current || reduced\n        ? { duration: 0 }\n        : pointer.current\n          ? motionTokens.spring.snappy\n          : { duration: 0.07, ease: [...motionTokens.ease.enter] };\n    // Rows sit inside positioned groups, so their offset is summed up to the list (layout transforms stay out of it).\n    let top = 0;\n    for (\n      let element: HTMLElement | null = node;\n      element && element !== listRef.current;\n      element = element.offsetParent as HTMLElement | null\n    )\n      top += element.offsetTop + (element === node ? 0 : element.clientTop);\n    animate(highlightY, top, move);\n    animate(highlightHeight, node.offsetHeight, move);\n    animate(highlightOpacity, 1, { duration: reduced ? 0 : 0.08 });\n    highlightShown.current = true;\n  }, [activeId, inputId, reduced, highlightY, highlightHeight, highlightOpacity]);\n\n  useEffect(() => {\n    if (!activeId) return;\n    document.getElementById(`${inputId}-${activeId}`)?.scrollIntoView({ block: \"nearest\" });\n  }, [activeId, inputId]);\n\n  function choose(item: CommandItem) {\n    onSelect?.(item);\n    setQuery(\"\");\n    setActiveIndex(null);\n    inputRef.current?.focus();\n  }\n  function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    pointer.current = false;\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      setActiveIndex((index) => Math.min((index ?? -1) + 1, Math.max(filtered.length - 1, 0)));\n    }\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setActiveIndex((index) => Math.max((index ?? filtered.length) - 1, 0));\n    }\n    if (event.key === \"Home\") {\n      event.preventDefault();\n      setActiveIndex(0);\n    }\n    if (event.key === \"End\") {\n      event.preventDefault();\n      setActiveIndex(Math.max(filtered.length - 1, 0));\n    }\n    // Enter runs the highlighted result; after typing, with nothing highlighted yet, it runs the top match.\n    const target = filtered[safeActiveIndex] ?? (query ? filtered[0] : undefined);\n    if (event.key === \"Enter\" && target) {\n      event.preventDefault();\n      choose(target);\n    }\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      if (query) {\n        setQuery(\"\");\n        setActiveIndex(null);\n      } else onClose?.();\n    }\n  }\n\n  return (\n    <motion.div\n      className=\"w-full origin-top overflow-hidden rounded-panel border border-line bg-surface shadow-floating\"\n      initial={reduced ? false : { opacity: 0, y: 6, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : {\n              default: motionTokens.spring.smooth,\n              opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n            }\n      }\n    >\n      <div className=\"relative flex min-h-[54px] items-center gap-2.5 border-b border-line-soft px-3.5 py-2 transition-[background] duration-fast ease-standard focus-within:bg-hover max-[480px]:gap-2 max-[480px]:px-[11px] motion-reduce:transition-none\">\n        <span className=\"grid size-[22px] flex-none place-items-center text-ink-3\">\n          <Search width={18} height={18} strokeWidth={1.75} aria-hidden=\"true\" />\n        </span>\n        <label className=\"sr-only\" htmlFor={inputId}>\n          {label}\n        </label>\n        <input\n          ref={inputRef}\n          id={inputId}\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded=\"true\"\n          aria-controls={`${inputId}-results`}\n          aria-activedescendant={activeId ? `${inputId}-${activeId}` : undefined}\n          value={query}\n          onChange={(event) => {\n            setQuery(event.target.value);\n            setActiveIndex(null);\n          }}\n          onKeyDown={handleKeyDown}\n          placeholder={placeholder}\n          autoComplete=\"off\"\n          className=\"min-w-0 flex-1 border-0 bg-transparent text-(length:--text-sm) text-ink outline-0 placeholder:text-ink-3 focus-visible:outline-none\"\n        />\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {query ? (\n            <motion.button\n              key=\"clear\"\n              className={cn(rowButton, \"grid size-6 border-0 bg-transparent\")}\n              type=\"button\"\n              aria-label=\"Clear search\"\n              onClick={() => {\n                setQuery(\"\");\n                setActiveIndex(null);\n                inputRef.current?.focus();\n              }}\n              initial={reduced ? false : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }\n              }\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }\n              }\n            >\n              <X width={15} height={15} aria-hidden=\"true\" />\n            </motion.button>\n          ) : null}\n        </AnimatePresence>\n        {onClose ? (\n          <button\n            className={cn(\n              rowButton,\n              // Drawn as a key cap beside the ⌘ K hint, so it keeps the key shape rather than the pill.\n              \"grid h-6 min-w-[27px] rounded-chip border border-line-soft bg-transparent px-1.5 text-xs leading-none font-medium\",\n            )}\n            type=\"button\"\n            aria-label=\"Close command palette\"\n            onClick={onClose}\n          >\n            Esc\n          </button>\n        ) : null}\n        <kbd className={cn(key, \"h-6 min-w-[38px] gap-0.5 rounded-chip border-line bg-hover px-1.5\")}>⌘ K</kbd>\n      </div>\n      {/* The frame animates to the list height; the list keeps its own scroll. */}\n      <motion.div\n        className=\"overflow-hidden\"\n        initial={false}\n        animate={{ height: listHeight }}\n        transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}\n      >\n        <motion.div\n          ref={listRef}\n          layoutScroll\n          id={`${inputId}-results`}\n          className=\"relative max-h-[342px] overflow-auto px-2 py-1.5 max-[480px]:p-1.5\"\n          role=\"listbox\"\n          aria-label=\"Command results\"\n        >\n          <motion.span\n            className=\"pointer-events-none absolute top-0 right-2 left-2 rounded-control bg-hover opacity-0 max-[480px]:right-1.5 max-[480px]:left-1.5\"\n            style={{ y: highlightY, height: highlightHeight, opacity: highlightOpacity }}\n            aria-hidden=\"true\"\n          />\n          {/* Results leave before the empty state arrives, so the frame makes one height change instead of two. */}\n          <AnimatePresence mode=\"wait\" initial={false}>\n            {filtered.length ? (\n              <motion.div\n                key=\"results\"\n                initial={reduced ? false : { opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }}\n                transition={enter}\n              >\n                {/* Filtered-out rows pop out of the flow as they fade, so the rest glide up and the frame starts closing on the same keystroke. */}\n                <AnimatePresence mode=\"popLayout\" initial={false} custom={glide}>\n                  {groupedItems.map(([group, entries]) => (\n                    <motion.div\n                      layout={glide ? \"position\" : false}\n                      className=\"relative [&+&]:mt-1.5\"\n                      role=\"group\"\n                      aria-label={group}\n                      key={group}\n                      variants={rowExit}\n                      initial={reduced ? false : { opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit=\"exit\"\n                      transition={reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }}\n                    >\n                      <span\n                        className=\"block px-2 pt-1.5 pb-[3px] text-xs leading-body font-medium text-ink-3\"\n                        aria-hidden=\"true\"\n                      >\n                        {group}\n                      </span>\n                      <AnimatePresence mode=\"popLayout\" initial={false} custom={glide}>\n                        {entries.map(({ item, index }) => (\n                          <motion.button\n                            layout={glide ? \"position\" : false}\n                            key={item.id}\n                            id={`${inputId}-${item.id}`}\n                            className=\"relative flex min-h-[34px] w-full cursor-pointer items-center gap-2.5 rounded-control border-0 bg-transparent px-2 text-left text-ink max-[480px]:gap-2 max-[340px]:px-1.5\"\n                            type=\"button\"\n                            role=\"option\"\n                            aria-selected={index === safeActiveIndex}\n                            onClick={() => choose(item)}\n                            onPointerMove={(event) => {\n                              // Ignore the synthetic moves browsers send while the list scrolls under a still pointer.\n                              if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y)\n                                return;\n                              lastPointer.current = { x: event.clientX, y: event.clientY };\n                              if (index !== safeActiveIndex) {\n                                pointer.current = true;\n                                setActiveIndex(index);\n                              }\n                            }}\n                            variants={rowExit}\n                            initial={reduced ? false : { opacity: 0 }}\n                            animate={{ opacity: 1 }}\n                            exit=\"exit\"\n                            transition={\n                              reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }\n                            }\n                          >\n                            <span\n                              className=\"grid size-4 flex-none place-items-center text-ink-3 transition-[color] duration-fast ease-standard motion-reduce:transition-none [&_svg]:size-4\"\n                              aria-hidden=\"true\"\n                            >\n                              {item.icon ?? <CommandIcon width={16} height={16} />}\n                            </span>\n                            <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                              <strong className=\"block max-w-full flex-none truncate text-(length:--text-sm) font-medium\">\n                                {item.label}\n                              </strong>\n                              {item.description && (\n                                <small className=\"block min-w-0 truncate text-(length:--text-xs) text-ink-3\">\n                                  {item.description}\n                                </small>\n                              )}\n                            </span>\n                            {item.shortcut && (\n                              <kbd\n                                className={cn(\n                                  key,\n                                  \"h-[18px] min-w-[18px] rounded-[5px] border-transparent bg-hover px-1 text-[11px]\",\n                                )}\n                              >\n                                {item.shortcut}\n                              </kbd>\n                            )}\n                          </motion.button>\n                        ))}\n                      </AnimatePresence>\n                    </motion.div>\n                  ))}\n                </AnimatePresence>\n              </motion.div>\n            ) : (\n              <motion.div\n                key=\"empty\"\n                className=\"grid justify-items-center gap-1 px-3 pt-[26px] pb-7 text-center\"\n                initial={reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }}\n                transition={enter}\n              >\n                <span className=\"mb-[5px] grid size-8 place-items-center text-ink-3\">\n                  <Search width={20} height={20} aria-hidden=\"true\" />\n                </span>\n                <strong className=\"text-(length:--text-sm) font-medium\">No matching actions</strong>\n                <small className=\"text-(length:--text-xs) text-ink-3\">Try a different word or clear the search.</small>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport default CommandPalette;\n",
      "type": "registry:component",
      "target": "@components/iris/command-palette/command-palette.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/command-palette",
  "meta": {
    "kind": "block",
    "group": "Overlays & disclosure",
    "export": "CommandPalette",
    "docs": "https://irisui.dev/components/command-palette"
  }
}
