shadimage
motion-lexicon
uianimated-comboboxsource: ok · hit

Animated combobox

Searches and selects a changing result set while preserving keyboard position.

npx shadcn add "https://motion-lexicon.pages.dev/r/animated-combobox.json"
free

Where it goes

kanban

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/9/2026, 2:40:45 PM

src/registry/components/animated-combobox.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export type AnimatedComboboxOption = { id: string; label: string; detail?: string };
export type AnimatedComboboxProps = { options: readonly AnimatedComboboxOption[]; label?: string; placeholder?: string; onChange?: (option: AnimatedComboboxOption | null) => void; className?: string };

export function AnimatedCombobox({ options, label = "Choose an option", placeholder = "Search…", onChange, className = "" }: AnimatedComboboxProps) {
  const reduced = useReducedMotion() === true; const inputId = useId(); const listId = useId(); const rootRef = useRef<HTMLDivElement>(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const [selected, setSelected] = useState<AnimatedComboboxOption | null>(null); const filtered = options.filter((item) => `${item.label} ${item.detail ?? ""}`.toLowerCase().includes(query.toLowerCase()));
  useEffect(() => { const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, []);
  const pick = (option: AnimatedComboboxOption) => { setSelected(option); setQuery(option.label); setOpen(false); onChange?.(option); };
  return <div ref={rootRef} className={`relative ${className}`}><label htmlFor={inputId} className="block text-[12px] font-medium text-[#292929]">{label}</label><input id={inputId} role="combobox" aria-expanded={open} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={open && filtered[active] ? `${listId}-${filtered[active].id}` : undefined} value={query} placeholder={placeholder} onFocus={() => setOpen(true)} onChange={(event) => { setQuery(event.target.value); setActive(0); setOpen(true); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); setOpen(true); setActive((value) => Math.min(value + 1, Math.max(0, filtered.length - 1))); } if (event.key === "ArrowUp") { event.preventDefault(); setOpen(true); setActive((value) => Math.max(0, value - 1)); } if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); pick(filtered[active]); } if (event.key === "Escape") setOpen(false); }} className="mt-1.5 min-h-11 w-full rounded-[10px] border border-black/[.12] bg-white px-3 text-[13px] outline-none placeholder:text-neutral-400 focus-visible:ring-2 focus-visible:ring-[#4568FF]" />{selected ? <span className="mt-1 block text-[10px] text-neutral-500">Selected: {selected.label}</span> : null}<AnimatePresence>{open ? <motion.ul id={listId} role="listbox" initial={reduced ? false : { opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? undefined : { opacity: 0, y: -4 }} transition={{ duration: reduced ? 0 : .16 }} className="absolute z-20 mt-1 max-h-52 w-full overflow-auto rounded-[10px] border border-black/[.1] bg-white p-1 shadow-[0_16px_35px_-20px_rgba(25,25,25,.35)]">{filtered.length ? filtered.map((option, optionIndex) => <li key={option.id} id={`${listId}-${option.id}`} role="option" aria-selected={selected?.id === option.id}><button type="button" onMouseEnter={() => setActive(optionIndex)} onClick={() => pick(option)} className={`min-h-11 w-full rounded-lg px-3 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-[#4568FF] ${active === optionIndex ? "bg-[#edf0ff]" : "hover:bg-neutral-50"}`}><strong className="block text-[12px] text-[#292929]">{option.label}</strong>{option.detail ? <span className="mt-0.5 block text-[10px] text-neutral-500">{option.detail}</span> : null}</button></li>) : <li role="status" className="px-3 py-4 text-[12px] text-neutral-500">No matches found.</li>}</motion.ul> : null}</AnimatePresence></div>;
}

More from motion-lexicon

Browse all components in this library →