UIForms and Inputsmotion/react

Combobox

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 props
280

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add combobox

Writes 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.

example.tsx
1import { Combobox } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Combobox />6  );7}

Props

PropTypeDefault
options

Options to choose from.

string[]10 frameworks
value

Controlled selected value.

stringundefined
defaultValue

Initial selected value when uncontrolled.

string''
onValueChange

Called with the newly selected value (an empty string when cleared).

(value: string) => voidundefined
open

Controlled open state of the listbox.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called when the listbox requests to open or close.

(open: boolean) => voidundefined
label

Visible field label, used as the accessible name.

string'Framework'
placeholder

Input placeholder.

string'Search frameworks…'
emptyMessage

Message shown (and announced) when no option matches.

string'No framework found.'
matchMode

How the query is matched against options (case-insensitive).

'contains' | 'startsWith''contains'
highlightMatches

Highlight the matched characters in each option.

booleantrue
showClear

Show an animated clear button while the field has text.

booleantrue
size

Field and option height / type size preset.

'sm' | 'md' | 'lg''md'
width

Field width in pixels; the popover matches it.

number280
accentColor

Color of the match highlight, check mark and focus ring.

string'#6366f1'
disabled

Disable the field.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/combobox.tsx
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}