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.
Install
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| value / onChangestring | null, or string[] with `multiple` | string | 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? })[] | (string | { value, label, hint?, keywords?, disabled? })[] | `keywords` are other words that find it — “Bombay” for Mumbai. |
| filter(option, query) => boolean | (option, query) => boolean | The default ignores case and accents and looks in the label and keywords. |
| emptyTextReactNode | ReactNode= "No match" | The row shown when nothing matches. |
| onQueryChange(query) => void | (query) => void | As the text changes — to fetch options from a server. |
| size"sm" | "md" | "lg" | "sm" | "md" | "lg"= "md" | 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.
"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>
);
}
"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>
);
}