{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/combobox/combobox.tsx","type":"registry:component","target":"@components/arc/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, \"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(null);\n const [height, setHeight] = useState(\"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
{children}
;\n}\n\nexport const Combobox = forwardRef(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(null);\n const inputRef = useRef(null);\n const optionRefs = useRef>({});\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((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) => {\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) => {\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
\n \n
\n \n {\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 \n {selectedOption && !disabled && (\n 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 \n \n )}\n \n \n
\n {description && {description}}\n \n {open && (\n \n \n
\n {filteredOptions.length ? filteredOptions.map((option, index) => (\n { 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 {option.label}\n {option.value === selectedValue && }\n
\n )) : {emptyMessage}}\n
\n \n \n )}\n \n \n );\n});\n\nCombobox.displayName = \"Combobox\";\n"},{"path":"registry/components/combobox/combobox.module.css","type":"registry:component","target":"@components/arc/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 { outline: 2px solid var(--focus-ring); 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://uiarc.dev/components/combobox","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/combobox","markdown":"https://uiarc.dev/components/combobox/markdown","tags":["field","search"]}}