← All components

Combobox

Type to narrow a list, with a highlight that slides and the matched letters set in medium.

Try “zur”, or “bombay”.

  • Ana Ribeiro
  • Esha Patel

Backspace takes the last one off.

The idea

The ARIA 1.2 combobox: focus never leaves the input and `aria-activedescendant` names the row under the keyboard, so a screen reader follows the arrows while the caret stays where you were typing. Typing marks the first match so Enter takes it; Escape closes and puts back what was chosen. The list is Select's glass and squash, but the row under the keyboard is one highlight sliding between rows — it snaps instead while you type, when the rows under it are changing anyway — and the part of each label that matched is set in medium, which is how you see why a row is there.

Composes Select and DropMenu and Field.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Combobox — usage16 lines
import { Field, Label, Combobox } from "mihirmodi-ui";

<Field>
  <Label>Office</Label>
  <Combobox
    value={city}
    onChange={setCity}
    options={[
      { value: "lis", label: "Lisbon", hint: "PT" },
      { value: "bom", label: "Mumbai", hint: "IN", keywords: ["Bombay"] },
    ]}
  />
</Field>

{/* Several, as chips */}
<Combobox multiple value={reviewers} onChange={setReviewers} options={people} placeholder="Add people" />

Props

PropNotes
value / onChangestring | null, or string[] with `multiple`Controlled. With `multiple`, choices become chips and the list stays open while you pick.
options(string | { value, label, hint?, keywords?, disabled? })[]`keywords` are other words that find it — “Bombay” for Mumbai.
filter(option, query) => booleanThe default ignores case and accents and looks in the label and keywords.
emptyTextReactNodeThe row shown when nothing matches.
onQueryChange(query) => voidAs the text changes — to fetch options from a server.
size"sm" | "md" | "lg"Matches Input.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/forms/Combobox.tsx455 lines
"use client";

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { DropMenu } from "../DropMenu.js";
import { CheckIcon, ChevronDownIcon, CloseIcon } from "../../lib/glyph.js";
import { EASE_GEIST, FLAT, SLIDE } from "../../lib/motion.js";
import { useField, useFieldControl } from "./Field.js";
import type { ControlSize } from "./Input.js";

const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;

export type ComboboxOption = {
  value: string;
  label: string;
  /** Right of the row — a code, a count. */
  hint?: ReactNode;
  /** Other words that should find it: "UK" for United Kingdom. */
  keywords?: string[];
  disabled?: boolean;
};

type Single = { multiple?: false; value: string | null; onChange: (value: string | null) => void };
type Multiple = { multiple: true; value: string[]; onChange: (value: string[]) => void };

export type ComboboxProps = (Single | Multiple) & {
  /** Strings, or `{ value, label, hint?, keywords?, disabled? }`. */
  options: (string | ComboboxOption)[];
  placeholder?: string;
  /** The row shown when nothing matches. */
  emptyText?: ReactNode;
  /** Replace the matching. The default ignores case and accents and looks in the label and keywords. */
  filter?: (option: ComboboxOption, query: string) => boolean;
  /** Called as the query changes — to fetch options from a server, say. */
  onQueryChange?: (query: string) => void;
  size?: ControlSize;
  disabled?: boolean;
  invalid?: boolean;
  id?: string;
  /** The accessible name, when there is no Label or `<label for>`. */
  "aria-label"?: string;
  className?: string;
};

/* With chips the box pads by 4px, so a chip is the box's height minus eight and
   its corner the box's minus four; the input beside them takes the chip's height. */
const BOX: Record<ControlSize, { h: string; cls: string; chip: string; chipH: string; chipX: string }> = {
  sm: { h: "28px", cls: "rounded-[8px] text-[13px]", chip: "rounded-[4px] text-[12px]", chipH: "h-5", chipX: "h-4 w-4 rounded-[3px]" },
  md: { h: "36px", cls: "rounded-[10px] text-[14px]", chip: "rounded-[6px] text-[13px]", chipH: "h-[26px]", chipX: "h-[18px] w-[18px] rounded-[4px]" },
  lg: { h: "40px", cls: "rounded-[10px] text-[14px]", chip: "rounded-[6px] text-[13px]", chipH: "h-[30px]", chipX: "h-5 w-5 rounded-[4px]" },
};

const fold = (s: string) =>
  s
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase();

const defaultFilter = (o: ComboboxOption, q: string) => {
  const n = fold(q.trim());
  return !n || [o.label, ...(o.keywords ?? [])].some((w) => fold(w).includes(n));
};

/** The label with the typed run set in medium — the part that answered the query. */
function Marked({ label, query }: { label: string; query: string }) {
  const n = fold(query.trim());
  const i = n ? fold(label).indexOf(n) : -1;
  // Folding can change length (a ligature); only mark when it did not.
  if (i < 0 || fold(label).length !== label.length) return <>{label}</>;
  return (
    <>
      {label.slice(0, i)}
      <span className="font-medium">{label.slice(i, i + n.length)}</span>
      {label.slice(i + n.length)}
    </>
  );
}

/**
 * A text field that narrows a list as you type — for choosing one of more
 * options than a Select can show comfortably, or several of them as chips.
 *
 * It is the ARIA 1.2 combobox: focus never leaves the input, and
 * `aria-activedescendant` names the row under the keyboard, so a screen reader
 * follows the arrow keys while the caret stays where you were typing. Typing
 * opens the list and marks the first match, so Enter takes it; the arrows move,
 * Escape closes and puts back what was chosen, and Tab leaves without picking.
 * A count of matches is spoken as the list narrows, and "No match" is a row of
 * its own rather than an empty panel that looks broken.
 *
 * The list is Select's — the same squash-and-stretch out of the field, the same
 * glass — but the row under the keyboard is one highlight that slides between
 * rows rather than a background on each, because one thing moving is easier to
 * follow than rows blinking. It snaps instead of sliding while you type, when
 * the rows under it are changing anyway. The part of each label that matched is
 * set in medium, which is how you see why a row is there.
 *
 * With `multiple`, choices become chips inside the box, the list stays open
 * while you pick, and Backspace in the empty field takes the last chip off.
 */
export function Combobox(props: ComboboxProps) {
  const {
    options,
    placeholder = "Search…",
    emptyText = "No match",
    filter = defaultFilter,
    onQueryChange,
    size = "md",
    disabled: ownDisabled,
    invalid,
    id,
    className = "",
    "aria-label": ariaLabel,
  } = props;
  const multiple = props.multiple === true;
  const reduce = useReducedMotion();
  const f = useField();
  const field = useFieldControl({ id, disabled: ownDisabled, invalid });
  const disabled = !!field.disabled;
  const listId = useId();
  const inputId = field.id ?? `${listId}-input`;
  const box = BOX[size];

  const items = useMemo(() => options.map((o) => (typeof o === "string" ? { value: o, label: o } : o)), [options]);
  const chosen: string[] = multiple ? (props.value as string[]) : props.value != null ? [props.value as string] : [];
  const single = !multiple ? (items.find((o) => o.value === props.value) ?? null) : null;

  const [open, setOpen] = useState(false);
  const [text, setText] = useState(single?.label ?? "");
  const [dirty, setDirty] = useState(false);
  const [active, setActive] = useState(-1);
  const [said, setSaid] = useState("");
  const input = useRef<HTMLInputElement>(null);
  const root = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const rows = useRef<(HTMLLIElement | null)[]>([]);
  // Chips already there on the first paint arrive with it; only later ones animate in.
  const mounted = useRef(false);
  useEffect(() => {
    mounted.current = true;
  }, []);
  const [bar, setBar] = useState<{ y: number; h: number; jump: boolean } | null>(null);

  const query = dirty ? text : "";
  // Index into `items`, so an option's id holds still while the list narrows.
  const shown = useMemo(() => items.map((o, i) => ({ o, i })).filter(({ o }) => filter(o, query)), [items, filter, query]);

  // A value set from outside shows in the field, unless someone is mid-word.
  useEffect(() => {
    if (!multiple && !dirty) setText(single?.label ?? "");
  }, [multiple, single?.label, dirty]);

  const shownAll = (i: number) => items.map((o, j) => ({ o, j })).filter(({ o }) => filter(o, "")).findIndex(({ j }) => j === i);

  const firstEnabled = (from = 0) => {
    const j = shown.findIndex(({ o }, k) => k >= from && !o.disabled);
    return j;
  };

  const show = () => {
    if (disabled) return;
    if (!open) {
      setSaid("");
      const sel = !multiple && single ? shown.findIndex(({ o }) => o.value === single.value) : -1;
      setActive(sel >= 0 ? sel : -1);
      setOpen(true);
    }
  };
  const hide = () => {
    setOpen(false);
    setActive(-1);
    setDirty(false);
    if (multiple) setText("");
    else setText(single?.label ?? "");
  };

  const pick = (k: number) => {
    const row = shown[k];
    if (!row || row.o.disabled) return;
    if (props.multiple) {
      const on = props.value.includes(row.o.value);
      props.onChange(on ? props.value.filter((v) => v !== row.o.value) : [...props.value, row.o.value]);
      setSaid(`${row.o.label} ${on ? "removed" : "added"}`);
      setText("");
      setDirty(false);
      // The list widens back to everything; keep the highlight on the row just picked.
      if (dirty) setActive(shownAll(row.i));
      onQueryChange?.("");
      input.current?.focus();
    } else {
      props.onChange(row.o.value);
      setText(row.o.label);
      setDirty(false);
      setOpen(false);
      setActive(-1);
    }
  };

  const remove = (value: string) => {
    if (!props.multiple) return;
    const o = items.find((x) => x.value === value);
    props.onChange(props.value.filter((v) => v !== value));
    if (o) setSaid(`${o.label} removed`);
    input.current?.focus();
  };

  // Click outside closes and puts the chosen label back.
  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) hide();
    };
    window.addEventListener("pointerdown", onDown);
    return () => window.removeEventListener("pointerdown", onDown);
  });

  // The highlight: measured from the row, so it lands wherever the row is. When
  // the rows themselves changed — a keystroke — it jumps rather than slides.
  const lastShown = useRef(shown);
  useIso(() => {
    const el = active >= 0 ? rows.current[active] : null;
    const jump = lastShown.current !== shown;
    lastShown.current = shown;
    setBar(el ? { y: el.offsetTop, h: el.offsetHeight, jump } : null);
    el?.scrollIntoView({ block: "nearest" });
  }, [active, open, shown]);

  const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (disabled) return;
    switch (e.key) {
      case "ArrowDown": {
        e.preventDefault();
        if (!open) {
          show();
          if (!e.altKey) setActive(Math.max(firstEnabled(), !multiple && single ? shown.findIndex(({ o }) => o.value === single.value) : -1));
          return;
        }
        for (let k = active + 1; k < shown.length; k++) if (!shown[k].o.disabled) return setActive(k);
        return;
      }
      case "ArrowUp": {
        e.preventDefault();
        if (!open) {
          show();
          for (let k = shown.length - 1; k >= 0; k--) if (!shown[k].o.disabled) return setActive(k);
          return;
        }
        for (let k = active - 1; k >= 0; k--) if (!shown[k].o.disabled) return setActive(k);
        return;
      }
      case "Enter":
        if (open && active >= 0) {
          e.preventDefault();
          pick(active);
        }
        return;
      case "Escape":
        if (open) {
          // Claimed only when it closed something, so a dialog around this still hears it otherwise.
          e.preventDefault();
          e.stopPropagation();
          hide();
        }
        return;
      case "Tab":
        if (open) hide();
        return;
      case "Backspace":
        if (props.multiple && text === "" && props.value.length) {
          e.preventDefault();
          remove(props.value[props.value.length - 1]);
        }
        return;
    }
  };

  const onType = (v: string) => {
    setSaid("");
    setText(v);
    setDirty(true);
    onQueryChange?.(v);
    if (!multiple && v === "" && props.value != null) (props.onChange as (v: string | null) => void)(null);
    if (!open) setOpen(true);
    // Mark the first match, so Enter takes it.
    const n = items.map((o, i) => ({ o, i })).filter(({ o }) => filter(o, v));
    setActive(v.trim() ? n.findIndex(({ o }) => !o.disabled) : -1);
  };

  const count = shown.length;
  const status = open ? (count ? `${count} ${count === 1 ? "result" : "results"}` : typeof emptyText === "string" ? emptyText : "No results") : "";
  const labelledBy = f?.labelId;
  const slide = bar?.jump ? { duration: 0 } : reduce ? FLAT : SLIDE;

  return (
    <LazyMotion features={domAnimation} strict>
      <div ref={root} className={`relative w-full min-w-0 ${className}`}>
        <div
          data-ui-input-group=""
          data-disabled={disabled || undefined}
          onMouseDown={(e) => {
            if ((e.target as HTMLElement).closest("button, input")) return;
            e.preventDefault();
            input.current?.focus();
            open ? hide() : show();
          }}
          style={{ "--ig-h": box.h } as React.CSSProperties}
          className={`ui-input-group relative flex w-full min-w-0 cursor-text flex-wrap items-center border-[0.5px] bg-background-100 text-gray-1000 transition-[border-color,box-shadow] duration-150 ease-geist has-[[aria-invalid=true]]:border-gray-1000 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 motion-reduce:transition-none ${
            open ? "border-gray-alpha-600 shadow-[0_0_0_3px_var(--color-gray-alpha-200)]" : "border-gray-alpha-400 hover:border-gray-alpha-600 focus-within:border-gray-alpha-600 focus-within:shadow-[0_0_0_3px_var(--color-gray-alpha-200)]"
          } ${multiple && chosen.length ? "gap-1 py-1 pl-1" : ""} ${box.cls}`}
        >
          {multiple && chosen.length > 0 && (
            <ul aria-label="Chosen" className="contents">
              {chosen.map((v) => {
                const o = items.find((x) => x.value === v);
                const label = o?.label ?? v;
                return (
                  <m.li
                    key={v}
                    initial={!mounted.current ? false : reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
                    animate={{ opacity: 1, scale: 1 }}
                    transition={reduce ? FLAT : { duration: 0.15, ease: EASE_GEIST }}
                    className={`flex min-w-0 max-w-full items-center gap-1 bg-gray-alpha-200 pl-2 pr-1 text-gray-1000 ${box.chip} ${box.chipH}`}
                  >
                    <span className="truncate">{label}</span>
                    <button
                      type="button"
                      tabIndex={-1}
                      disabled={disabled}
                      aria-label={`Remove ${label}`}
                      onMouseDown={(e) => e.preventDefault()}
                      onClick={() => remove(v)}
                      className={`flex shrink-0 items-center justify-center text-quiet transition-colors duration-150 hover:bg-gray-alpha-300 hover:text-gray-1000 ${box.chipX}`}
                    >
                      <CloseIcon className="h-2.5 w-2.5" />
                    </button>
                  </m.li>
                );
              })}
            </ul>
          )}
          <input
            ref={input}
            id={inputId}
            type="text"
            role="combobox"
            autoComplete="off"
            autoCapitalize="off"
            spellCheck={false}
            disabled={disabled}
            required={field.required && chosen.length === 0}
            aria-label={ariaLabel}
            aria-expanded={open}
            aria-controls={listId}
            aria-autocomplete="list"
            aria-activedescendant={open && active >= 0 && shown[active] ? `${listId}-${shown[active].i}` : undefined}
            aria-invalid={field["aria-invalid"]}
            aria-describedby={field["aria-describedby"]}
            aria-errormessage={field["aria-errormessage"]}
            data-ui-group-control=""
            value={text}
            placeholder={multiple && chosen.length ? "" : placeholder}
            onChange={(e) => onType(e.target.value)}
            onKeyDown={onKey}
            onClick={show}
            className={`min-w-[64px] flex-1 bg-transparent pr-1 text-gray-1000 placeholder:text-faint disabled:cursor-not-allowed ${
              multiple && chosen.length ? `${box.chipH} pl-1` : `h-[calc(var(--ig-h)-1px)] ${size === "sm" ? "pl-2.5" : "pl-3"}`
            }`}
          />
          <button
            type="button"
            tabIndex={-1}
            disabled={disabled}
            aria-label={open ? "Hide options" : "Show options"}
            onMouseDown={(e) => {
              e.preventDefault();
              e.stopPropagation();
              input.current?.focus();
              open ? hide() : show();
            }}
            className={`flex shrink-0 items-center self-stretch text-quiet ${size === "sm" ? "px-2" : "px-3"}`}
          >
            <span className={`transition-transform duration-200 ease-geist motion-reduce:transition-none ${open ? "rotate-180" : ""}`}>
              <ChevronDownIcon className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
            </span>
          </button>
        </div>

        <DropMenu open={open} placement="down" origin="origin-top-left" className="absolute left-0 top-full z-50 mt-1 w-full">
          <div className="glass-panel dock-shadow overflow-hidden rounded-[12px] border-[0.5px] border-gray-alpha-400">
            <ul
              ref={list}
              id={listId}
              role="listbox"
              aria-labelledby={ariaLabel ? undefined : labelledBy}
              aria-label={ariaLabel}
              aria-multiselectable={multiple || undefined}
              onMouseDown={(e) => e.preventDefault()}
              className={`relative max-h-64 overflow-y-auto ${count ? "p-1" : ""}`}
            >
              {bar && (
                <m.li
                  aria-hidden
                  role="presentation"
                  className="pointer-events-none absolute inset-x-1 top-0 rounded-[8px] bg-gray-alpha-200"
                  initial={false}
                  animate={{ y: bar.y, height: bar.h }}
                  transition={slide}
                />
              )}
              {shown.map(({ o, i }, k) => {
                const on = chosen.includes(o.value);
                return (
                  <li
                    key={o.value}
                    ref={(el) => {
                      rows.current[k] = el;
                    }}
                    id={`${listId}-${i}`}
                    role="option"
                    aria-selected={on}
                    aria-disabled={o.disabled || undefined}
                    onMouseMove={() => !o.disabled && active !== k && setActive(k)}
                    onClick={() => pick(k)}
                    className={`relative flex h-8 items-center gap-2.5 rounded-[8px] px-2.5 text-[13px] text-gray-1000 ${
                      o.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
                    }`}
                  >
                    <span className={`flex w-3.5 shrink-0 justify-center ${on ? "text-gray-1000" : "text-transparent"}`}>
                      <CheckIcon className="h-3 w-3" />
                    </span>
                    <span className="min-w-0 flex-1 truncate">
                      <Marked label={o.label} query={query} />
                    </span>
                    {o.hint && <span className="shrink-0 text-[11px] text-faint">{o.hint}</span>}
                  </li>
                );
              })}
            </ul>
            {count === 0 && (
              <div className="p-1">
                <div className="flex h-8 items-center gap-2.5 px-2.5 text-[13px] text-faint">
                  <span aria-hidden className="w-3.5 shrink-0" />
                  {emptyText}
                </div>
              </div>
            )}
          </div>
        </DropMenu>
        <span className="sr-only" role="status" aria-live="polite">
          {said || status}
        </span>
      </div>
    </LazyMotion>
  );
}
packages/ui/src/components/DropMenu.tsx105 lines
"use client";

import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";

/* Two springs of deliberately different character — the effect comes from letting
   them disagree. The grow axis is light and underdamped, so it sails past its
   final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
   late. Through the overshoot the panel is briefly taller and narrower than it
   ends up, then it settles: squash and stretch out of physics rather than out of
   a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/* Closing is quick and flat. A bounce on the way out reads as indecision — the
   panel looks like it is not sure it wants to go. Open and close are not mirror
   images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

export type DropPlacement = "up" | "down";

export type DropMenuProps = {
  open: boolean;
  /** Which way it grows. `up` for a bottom dock, `down` for a header. */
  placement?: DropPlacement;
  /** `transform-origin` utility. Defaults to the edge the menu grows from. */
  origin?: string;
  id?: string;
  className?: string;
  style?: CSSProperties;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
  children: ReactNode;
};

/**
 * The open/close motion for a floating menu that grows out of a trigger sitting
 * against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
 * under a header.
 *
 * It animates a wrapper and nothing else, so what you put inside is yours: give
 * the child the glass, the border and the padding. That split is deliberate — a
 * `backdrop-filter` on an element that is itself being transformed is weakened
 * or dropped entirely by most engines, so the blur must live on a child that is
 * holding still.
 *
 * The panel mounts only while open, which keeps a closed menu out of the tab
 * order and off the paint. The wrapper is always present, so a trigger's
 * `aria-controls` still resolves to something. Under reduced motion the elastic
 * scaling drops out and a plain fade remains.
 *
 * You own `open` — this is not a popover with its own state. Escape handling,
 * click-outside and hover intent belong to the trigger, which knows what it is.
 */
export function DropMenu({
  open,
  placement = "up",
  origin,
  id,
  className = "",
  style,
  onMouseEnter,
  onMouseLeave,
  children,
}: DropMenuProps) {
  const reduce = useReducedMotion();
  // Start nudged back toward the trigger, so it reads as coming out of it.
  const away = placement === "up" ? 4 : -4;
  const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");

  return (
    <div
      id={id}
      style={style}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
      // Empty and inert when closed, so it can never sit over the page as a trap.
      className={`${className} ${open ? "" : "pointer-events-none"}`}
    >
      <LazyMotion features={domAnimation} strict>
        <AnimatePresence>
          {open && (
            <m.div
              className={anchor}
              initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
              animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
              exit={
                reduce
                  ? { opacity: 0, transition: CLOSE }
                  : { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
              }
              transition={
                reduce
                  ? { duration: 0.15 }
                  : { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
              }
            >
              {children}
            </m.div>
          )}
        </AnimatePresence>
      </LazyMotion>
    </div>
  );
}