{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "Search and select from a list without leaving the field.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/combobox/combobox.tsx",
      "type": "registry:component",
      "target": "@components/xzero/combobox/combobox.tsx",
      "content": "\"use client\";\n\nimport { animate, AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { InputHTMLAttributes, KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./combobox.module.css\";\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n  keywords?: string[];\n}\n\nexport interface ComboboxProps\n  extends Omit<InputHTMLAttributes<HTMLInputElement>, \"value\" | \"defaultValue\" | \"onChange\" | \"placeholder\"> {\n  label: string;\n  options: ComboboxOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  description?: string;\n  placeholder?: string;\n  emptyMessage?: string;\n  className?: string;\n}\n\n/** Follows the listbox height with a critically damped spring, so filtering never snaps the menu. */\nfunction AutoHeight({ children, reduceMotion }: { children: ReactNode; reduceMotion: boolean | null }) {\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const inner = innerRef.current;\n    if (!inner) return;\n    const observer = new ResizeObserver(() => setHeight(inner.offsetHeight));\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.div className={styles.autoHeight} initial={false} animate={{ height }} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.smooth}><div ref={innerRef}>{children}</div></motion.div>;\n}\n\nexport const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(function Combobox(\n  {\n    label,\n    options,\n    value: controlledValue,\n    defaultValue = \"\",\n    onValueChange,\n    description,\n    placeholder = \"Search or select…\",\n    emptyMessage = \"No matches found\",\n    id,\n    className,\n    disabled,\n    onFocus,\n    ...inputProps\n  },\n  forwardedRef,\n) {\n  const generatedId = useId();\n  const controlId = id ?? generatedId;\n  const listboxId = `${controlId}-listbox`;\n  const hintId = description ? `${controlId}-description` : undefined;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const optionRefs = useRef<Record<string, HTMLDivElement | null>>({});\n  const reduceMotion = useReducedMotion();\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const selectedValue = controlledValue ?? uncontrolledValue;\n  const selectedOption = options.find((option) => option.value === selectedValue);\n\n  useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);\n\n  // A chosen label settles into the field: it rises in from below with a soft blur. Clearing fades the\n  // placeholder in instead of snapping from the old label.\n  const settledValue = useRef(selectedValue);\n  useEffect(() => {\n    if (settledValue.current === selectedValue) return;\n    settledValue.current = selectedValue;\n    const input = inputRef.current;\n    if (!input || reduceMotion || (selectedValue && open)) return;\n    const controls = selectedValue\n      ? animate(input, { opacity: [0, 1], y: [\"0.35em\", \"0em\"], filter: [`blur(${motionTokens.blur.soft}px)`, \"blur(0px)\"] }, { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] })\n      : animate(input, { opacity: [0, 1] }, { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] });\n    return () => controls.complete();\n  }, [selectedValue, reduceMotion, open]);\n\n  const filteredOptions = useMemo(() => {\n    const normalizedQuery = query.trim().toLocaleLowerCase();\n    if (!normalizedQuery) return options;\n    return options.filter((option) =>\n      [option.label, ...(option.keywords ?? [])].some((term) => term.toLocaleLowerCase().includes(normalizedQuery)),\n    );\n  }, [options, query]);\n\n  const enabledIndices = filteredOptions.reduce<number[]>((indices, option, index) => {\n    if (!option.disabled) indices.push(index);\n    return indices;\n  }, []);\n\n  useEffect(() => {\n    if (!open) return;\n    const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;\n    const option = activeOption ? optionRefs.current[activeOption.value] : null;\n    const listbox = option?.parentElement;\n    if (option && listbox) {\n      const top = option.offsetTop;\n      const bottom = top + option.offsetHeight;\n      if (top < listbox.scrollTop) listbox.scrollTop = top;\n      else if (bottom > listbox.scrollTop + listbox.clientHeight) listbox.scrollTop = bottom - listbox.clientHeight;\n    }\n  }, [activeIndex, filteredOptions, open]);\n\n  useEffect(() => {\n    const handlePointerDown = (event: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {\n        setOpen(false);\n        setQuery(\"\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, []);\n\n  const choose = (option: ComboboxOption) => {\n    if (option.disabled) return;\n    setUncontrolledValue(option.value);\n    onValueChange?.(option.value);\n    setQuery(\"\");\n    setOpen(false);\n    inputRef.current?.focus();\n  };\n\n  const clear = (event: ReactMouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    setUncontrolledValue(\"\");\n    onValueChange?.(\"\");\n    setQuery(\"\");\n    setOpen(true);\n    inputRef.current?.focus();\n  };\n\n  const openMenu = () => {\n    if (disabled) return;\n    setOpen(true);\n    setQuery(\"\");\n    setActiveIndex(-1);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) {\n        openMenu();\n        return;\n      }\n      if (!enabledIndices.length) return;\n      const currentPosition = enabledIndices.indexOf(activeIndex);\n      const nextPosition = event.key === \"ArrowDown\"\n        ? (currentPosition + 1) % enabledIndices.length\n        : (currentPosition - 1 + enabledIndices.length) % enabledIndices.length;\n      setActiveIndex(enabledIndices[nextPosition]!);\n      return;\n    }\n    if (event.key === \"Enter\" && open && activeIndex >= 0) {\n      event.preventDefault();\n      const option = filteredOptions[activeIndex];\n      if (option) choose(option);\n      return;\n    }\n    if (event.key === \"Escape\" && open) {\n      event.preventDefault();\n      setOpen(false);\n      setQuery(\"\");\n      return;\n    }\n  };\n\n  const inputValue = open ? query : selectedOption?.label ?? \"\";\n  const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;\n\n  return (\n    <div ref={rootRef} className={styles.field}>\n      <label htmlFor={controlId}>{label}</label>\n      <div className={[styles.control, open ? styles.open : \"\", disabled ? styles.disabled : \"\", className ?? \"\"].filter(Boolean).join(\" \")}>\n        <Search className={styles.searchIcon} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        <input\n          {...inputProps}\n          ref={inputRef}\n          id={controlId}\n          type=\"text\"\n          role=\"combobox\"\n          value={inputValue}\n          // While searching, the chosen label stays in place as muted placeholder copy instead of vanishing.\n          placeholder={selectedOption?.label ?? placeholder}\n          disabled={disabled}\n          aria-describedby={hintId}\n          aria-expanded={open}\n          aria-controls={open ? listboxId : undefined}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={open && activeOption ? `${controlId}-option-${activeOption.value}` : undefined}\n          onFocus={(event) => {\n            onFocus?.(event);\n            openMenu();\n          }}\n          onClick={openMenu}\n          onChange={(event) => {\n            setQuery(event.target.value);\n            setOpen(true);\n            setActiveIndex(-1);\n          }}\n          onKeyDown={handleKeyDown}\n        />\n        <AnimatePresence initial={false}>\n          {selectedOption && !disabled && (\n            <motion.button\n              type=\"button\"\n              className={styles.clear}\n              aria-label=\"Clear selection\"\n              onMouseDown={event => event.preventDefault()}\n              onClick={clear}\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } } }}\n              exit={{ opacity: 0, ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n              whileTap={{ scale: reduceMotion ? 1 : 0.96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n            >\n              <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <ChevronDown className={styles.chevron} size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n      </div>\n      {description && <span id={hintId} className={styles.hint}>{description}</span>}\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            className={styles.popover}\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}\n            animate={{ opacity: 1, y: 0, scale: 1, transition: reduceMotion ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } }}\n            exit={{ opacity: 0, ...(reduceMotion ? {} : { y: -4, scale: 0.98 }), transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n            role=\"presentation\"\n          >\n            <AutoHeight reduceMotion={reduceMotion}>\n            <div id={listboxId} className={styles.listbox} role=\"listbox\" aria-label={`${label} options`}>\n              {filteredOptions.length ? filteredOptions.map((option, index) => (\n                <div\n                  key={option.value}\n                  ref={(element) => { optionRefs.current[option.value] = element; }}\n                  id={`${controlId}-option-${option.value}`}\n                  className={styles.option}\n                  data-active={index === activeIndex ? \"true\" : undefined}\n                  data-disabled={option.disabled ? \"true\" : undefined}\n                  role=\"option\"\n                  aria-selected={option.value === selectedValue}\n                  aria-disabled={option.disabled || undefined}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onMouseEnter={() => !option.disabled && setActiveIndex(index)}\n                  onClick={() => choose(option)}\n                >\n                  <span>{option.label}</span>\n                  {option.value === selectedValue && <Check className={styles.check} size={16} strokeWidth={1.75} aria-hidden=\"true\" />}\n                </div>\n              )) : <motion.div className={styles.empty} role=\"status\" initial={reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{emptyMessage}</motion.div>}\n            </div>\n            </AutoHeight>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n});\n\nCombobox.displayName = \"Combobox\";\n"
    },
    {
      "path": "registry/components/combobox/combobox.module.css",
      "type": "registry:component",
      "target": "@components/xzero/combobox/combobox.module.css",
      "content": ".field { position: relative; display: grid; gap: var(--space-2); min-width: 0; }\n.field label { font-size: var(--text-sm); font-weight: 500; }\n.control { position: relative; z-index: 2; display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); color: var(--foreground); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not(.disabled) { border-color: var(--border-strong); background: var(--surface-muted); } }\n.control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); }\n.control.open { border-color: var(--border-strong); background: var(--surface-muted); }\n.control.disabled { opacity: .5; cursor: not-allowed; }\n.searchIcon { flex: 0 0 auto; color: var(--text-muted); }\n.control input { width: 100%; min-width: 0; border: 0; outline: 0; padding: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }\n.control input:focus-visible { outline: none; }\n.control input::placeholder { color: var(--text-muted); }\n.chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring); }\n.open .chevron { transform: rotate(180deg); }\n.clear { display: grid; width: 22px; height: 22px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .clear:hover { background: var(--surface-muted); color: var(--foreground); } }\n.clear:focus-visible { --focus-outline-offset: 1px; }\n.hint { color: var(--text-muted); font-size: var(--text-xs); }\n.popover { position: absolute; z-index: 80; top: calc(100% + 8px); right: 0; left: 0; overflow: hidden; transform-origin: top center; border: 1px solid var(--border); border-radius: var(--radius-panel); padding: 5px; background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); will-change: transform, opacity; }\n.autoHeight { overflow: hidden; }\n.listbox { max-height: min(300px, 40vh); overflow-y: auto; overscroll-behavior: contain; }\n/* Arrow keys move the highlight often, so it changes almost instantly. */\n.option { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-3); border-radius: calc(var(--radius-panel) - 5px); padding: 0 12px; color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; user-select: none; transition: background-color 80ms var(--ease-standard), color 80ms var(--ease-standard); }\n.option > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.option[data-active=\"true\"] { background: var(--surface-muted); }\n.option[data-disabled=\"true\"] { opacity: .42; cursor: not-allowed; }\n.check { flex: 0 0 auto; color: var(--foreground); }\n.empty { padding: var(--space-3); color: var(--text-muted); font-size: var(--text-sm); }\n@media (prefers-reduced-motion: reduce) { .control, .chevron, .clear, .option { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/combobox",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "field",
      "search"
    ],
    "docs": "https://ui.xzero.co.uk/components/combobox",
    "markdown": "https://ui.xzero.co.uk/components/combobox/markdown"
  }
}