A filterable autocomplete input: typing narrows a popover listbox, highlights the matched characters and shows an animated empty state when nothing matches. The popover scales out of the field, flips above it near the viewport edge, and marks the selected option with a check.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add comboboxWrites components/ui/combobox.tsx and components/styles/combobox.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Combobox } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Combobox />6 );7}Props
Accessibility
Implements the WAI-ARIA editable combobox with list autocomplete: the input is role="combobox" with aria-autocomplete="list", aria-expanded, aria-controls and aria-activedescendant, so DOM focus always stays in the input. The popup is role="listbox" of role="option" items with aria-selected. ArrowDown/ArrowUp open and move (wrapping), Alt+ArrowDown opens without moving, Enter selects, Escape closes and restores the text (or clears it when already closed). The empty state is a role="status" live region. The visible label is a real <label>. Under prefers-reduced-motion the popup only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/combobox.module.css";7 8export interface ComboboxProps {9 /** Options to choose from */10 options?: string[];11 /** Controlled selected value */12 value?: string;13 /** Initial selected value when uncontrolled */14 defaultValue?: string;15 /** Called with the newly selected value ("" when cleared) */16 onValueChange?: (value: string) => void;17 /** Controlled open state of the listbox */18 open?: boolean;19 /** Initial open state when uncontrolled */20 defaultOpen?: boolean;21 /** Called when the listbox requests to open or close */22 onOpenChange?: (open: boolean) => void;23 /** Visible field label (accessible name) */24 label?: string;25 /** Input placeholder */26 placeholder?: string;27 /** Message shown when no option matches */28 emptyMessage?: string;29 /** How the query is matched against options */30 matchMode?: "contains" | "startsWith";31 /** Highlight the matched part of each option */32 highlightMatches?: boolean;33 /** Show a clear button when there is text */34 showClear?: boolean;35 /** Size preset */36 size?: "sm" | "md" | "lg";37 /** Field width in pixels */38 width?: number;39 /** Accent color for highlights, check mark and focus ring */40 accentColor?: string;41 /** Disable the field */42 disabled?: boolean;43 /** Optional className merged onto the root */44 className?: string;45 /** Optional inline styles merged onto the root */46 style?: React.CSSProperties;47}48 49const DEFAULT_OPTIONS = ["Next.js", "SvelteKit", "Nuxt", "Remix", "Astro", "SolidStart", "Gatsby", "Qwik City", "Angular", "Vite"];50const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];51const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;52 53function readThemeVars(el: Element | null): Record<string, string> {54 const out: Record<string, string> = {};55 if (!el) return out;56 const cs = getComputedStyle(el);57 for (const t of THEME_TOKENS) {58 const v = cs.getPropertyValue(t).trim();59 if (v) out[t] = v;60 }61 return out;62}63 64function Highlight({ text, query, mode, className }: { text: string; query: string; mode: "contains" | "startsWith"; className: string }) {65 const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;66 if (at < 0 || (mode === "startsWith" && at !== 0)) return <>{text}</>;67 return (68 <>69 {text.slice(0, at)}70 <mark className={className}>{text.slice(at, at + query.length)}</mark>71 {text.slice(at + query.length)}72 </>73 );74}75 76export function Combobox({77 options = DEFAULT_OPTIONS,78 value: valueProp,79 defaultValue = "",80 onValueChange,81 open: openProp,82 defaultOpen = false,83 onOpenChange,84 label = "Framework",85 placeholder = "Search frameworks…",86 emptyMessage = "No framework found.",87 matchMode = "contains",88 highlightMatches = true,89 showClear = true,90 size = "md",91 width = 280,92 accentColor = "#6366f1",93 disabled = false,94 className = "",95 style,96}: ComboboxProps) {97 const reduce = useReducedMotion();98 const id = React.useId();99 const [innerValue, setInnerValue] = React.useState(defaultValue);100 const [innerOpen, setInnerOpen] = React.useState(defaultOpen);101 const value = valueProp !== undefined ? valueProp : innerValue;102 const open = !disabled && (openProp !== undefined ? openProp : innerOpen);103 const [query, setQuery] = React.useState(value);104 const [filtering, setFiltering] = React.useState(false);105 const [active, setActive] = React.useState(-1);106 const [mounted, setMounted] = React.useState(false);107 const [pos, setPos] = React.useState<React.CSSProperties>({});108 const [below, setBelow] = React.useState(true);109 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});110 const fieldRef = React.useRef<HTMLDivElement>(null);111 const inputRef = React.useRef<HTMLInputElement>(null);112 const listRef = React.useRef<HTMLUListElement>(null);113 114 const q = filtering ? query.trim() : "";115 const results = React.useMemo(() => {116 const lq = q.toLowerCase();117 return lq ? options.filter((o) => (matchMode === "startsWith" ? o.toLowerCase().startsWith(lq) : o.toLowerCase().includes(lq))) : options;118 }, [options, q, matchMode]);119 120 React.useEffect(() => setMounted(true), []);121 React.useEffect(() => setQuery(value), [value]);122 123 const setOpen = (next: boolean) => {124 if (next === open) return;125 if (openProp === undefined) setInnerOpen(next);126 onOpenChange?.(next);127 };128 129 const commit = (v: string) => {130 if (valueProp === undefined) setInnerValue(v);131 if (v !== value) onValueChange?.(v);132 setQuery(v);133 setFiltering(false);134 setOpen(false);135 };136 137 const place = React.useCallback(() => {138 const f = fieldRef.current;139 if (!f) return;140 const r = f.getBoundingClientRect();141 const fitsBelow = window.innerHeight - r.bottom >= 260 || window.innerHeight - r.bottom >= r.top;142 setBelow(fitsBelow);143 setPos(fitsBelow ? { left: r.left, width: r.width, top: r.bottom + 6 } : { left: r.left, width: r.width, bottom: window.innerHeight - r.top + 6 });144 }, []);145 146 useIsoLayoutEffect(() => {147 if (!open) return;148 setThemeVars(readThemeVars(fieldRef.current));149 place();150 window.addEventListener("resize", place);151 window.addEventListener("scroll", place, true);152 return () => {153 window.removeEventListener("resize", place);154 window.removeEventListener("scroll", place, true);155 };156 }, [open, place]);157 158 // Keep the active option valid and visible.159 React.useEffect(() => {160 if (!open) return setActive(-1);161 setActive((a) => {162 if (filtering) return results.length ? 0 : -1;163 const sel = results.indexOf(value);164 return a >= 0 && a < results.length ? a : sel;165 });166 }, [open, results, filtering, value]);167 168 React.useEffect(() => {169 if (active >= 0) listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });170 }, [active]);171 172 const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {173 const n = results.length;174 if (e.key === "ArrowDown" || e.key === "ArrowUp") {175 e.preventDefault();176 if (!open) {177 setOpen(true);178 if (!e.altKey && n) setActive(e.key === "ArrowDown" ? 0 : n - 1);179 return;180 }181 if (n) setActive((a) => (e.key === "ArrowDown" ? (a + 1) % n : a <= 0 ? n - 1 : a - 1));182 } else if (e.key === "Enter") {183 if (open && active >= 0 && results[active]) {184 e.preventDefault();185 commit(results[active]);186 }187 } else if (e.key === "Escape") {188 e.preventDefault();189 if (open) {190 setOpen(false);191 setQuery(value);192 setFiltering(false);193 } else commit("");194 }195 };196 197 const listId = `${id}-list`;198 const optId = (i: number) => `${id}-opt-${i}`;199 200 return (201 <div className={`${styles.root} ${styles[size] ?? ""} ${className}`.trim()} style={{ "--mf-cb-accent": accentColor, "--mf-cb-w": `${width}px`, ...style } as React.CSSProperties}>202 {label && (203 <label htmlFor={`${id}-input`} id={`${id}-label`} className={styles.label}>204 {label}205 </label>206 )}207 <div ref={fieldRef} className={styles.field} data-open={open ? "" : undefined} data-disabled={disabled ? "" : undefined}>208 <svg aria-hidden="true" className={styles.icon} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">209 <circle cx="11" cy="11" r="7" />210 <path d="m20 20-3.5-3.5" />211 </svg>212 <input213 ref={inputRef}214 id={`${id}-input`}215 type="text"216 role="combobox"217 aria-autocomplete="list"218 aria-expanded={open}219 aria-controls={listId}220 aria-activedescendant={open && active >= 0 ? optId(active) : undefined}221 autoComplete="off"222 spellCheck={false}223 disabled={disabled}224 placeholder={placeholder}225 value={query}226 className={styles.input}227 onChange={(e) => {228 setQuery(e.target.value);229 setFiltering(true);230 setOpen(true);231 }}232 onClick={() => setOpen(!open)}233 onKeyDown={onKeyDown}234 onBlur={() => {235 if (query.trim() === "" && value) return commit("");236 setOpen(false);237 setFiltering(false);238 setQuery(value);239 }}240 />241 <AnimatePresence>242 {showClear && query && !disabled && (243 <motion.button244 type="button"245 aria-label="Clear"246 tabIndex={-1}247 className={styles.clear}248 initial={{ opacity: 0, scale: 0.6 }}249 animate={{ opacity: 1, scale: 1 }}250 exit={{ opacity: 0, scale: 0.6 }}251 transition={{ duration: reduce ? 0 : 0.15 }}252 onPointerDown={(e) => e.preventDefault()}253 onClick={() => {254 commit("");255 inputRef.current?.focus();256 }}257 >258 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">259 <path d="M18 6 6 18M6 6l12 12" />260 </svg>261 </motion.button>262 )}263 </AnimatePresence>264 </div>265 {mounted &&266 createPortal(267 <AnimatePresence>268 {open && (269 <motion.div270 className={`${styles.popover} ${styles[size] ?? ""}`.trim()}271 style={{ ...themeVars, ...pos, "--mf-cb-accent": accentColor, transformOrigin: below ? "top center" : "bottom center" } as React.CSSProperties}272 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: below ? -6 : 6 }}273 animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}274 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: below ? -4 : 4 }}275 transition={{ duration: reduce ? 0.08 : 0.16, ease: [0.16, 1, 0.3, 1] }}276 onPointerDown={(e) => e.preventDefault()}277 >278 <ul ref={listRef} id={listId} role="listbox" aria-labelledby={label ? `${id}-label` : undefined} aria-label={label ? undefined : "Suggestions"} className={styles.list}>279 {results.map((o, i) => (280 <li281 key={o}282 id={optId(i)}283 role="option"284 aria-selected={o === value}285 data-index={i}286 data-active={i === active ? "" : undefined}287 className={styles.option}288 onPointerMove={() => active !== i && setActive(i)}289 onClick={() => commit(o)}290 >291 <span className={styles.optionLabel}>{highlightMatches ? <Highlight text={o} query={q} mode={matchMode} className={styles.mark} /> : o}</span>292 {o === value && (293 <svg aria-hidden="true" className={styles.check} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">294 <path d="M5 12.5l4.5 4.5L19 7.5" />295 </svg>296 )}297 </li>298 ))}299 </ul>300 {results.length === 0 && (301 <motion.div role="status" className={styles.empty} initial={{ opacity: 0, y: reduce ? 0 : 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: reduce ? 0 : 0.18 }}>302 {emptyMessage}303 </motion.div>304 )}305 </motion.div>306 )}307 </AnimatePresence>,308 document.body,309 )}310 </div>311 );312}