Multi Select Chips Input
Machine-coding brief for multi-select chips — tags input, keyboard remove, suggestions, a11y listbox, and controlled values.
- machine-coding
- interview
- react
- a11y
Problem statement
Build a multi-select chips input: selected values appear as removable chips; a text field filters options or creates tags. Common as “tags”, “assignees”, or “categories”. Interviewers score chip keyboard UX, backspace-to-remove, and combobox semantics.
Requirements
Must have
- Controlled
value: string[]+onChange - Chips with remove (click + keyboard)
- Text input to filter a provided
optionslist or freeform tags (clarify) - Add on Enter / comma / select suggestion
- No duplicate values
- Backspace on empty input removes last chip
Should have
- Dropdown suggestions (combobox)
- Highlight active suggestion; Enter selects
- Disabled option support; max selections
Nice to have
- Paste “a, b, c”
- Drag reorder chips
- Async option search
Planning (5 minutes out loud)
- Freeform vs select-only — product rule
- IDs vs labels — store
valueids if options have both - Focus — input always the typing target; chips are buttons before it
- MVP — chips + freeform Enter + backspace; then suggestions
- A11y — describe as list of selected + combobox
Architecture
MultiSelect
├── ChipList
│ └── Chip (button remove)
├── Input
└── SuggestionList
API
type MultiSelectProps = {
value: string[];
onChange: (next: string[]) => void;
options?: string[]; // if omitted, freeform only
placeholder?: string;
max?: number;
allowFreeform?: boolean; // default true
};
Implementation sketch
function MultiSelect({
value,
onChange,
options = [],
placeholder = "Add…",
max,
allowFreeform = true,
}: MultiSelectProps) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const filtered = options
.filter((o) => !value.includes(o))
.filter((o) => o.toLowerCase().includes(query.trim().toLowerCase()));
function add(item: string) {
const v = item.trim();
if (!v || value.includes(v)) return;
if (max != null && value.length >= max) return;
onChange([...value, v]);
setQuery("");
setActive(0);
}
function remove(item: string) {
onChange(value.filter((x) => x !== item));
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Backspace" && query === "" && value.length) {
remove(value[value.length - 1]);
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (open && filtered[active]) add(filtered[active]);
else if (allowFreeform) add(query);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActive((i) => Math.min(filtered.length - 1, i + 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(0, i - 1));
}
if (e.key === "Escape") setOpen(false);
if (e.key === "," && allowFreeform) {
e.preventDefault();
add(query);
}
}
const listId = useId();
return (
<div className="ms">
<div className="ms-control" onClick={() => setOpen(true)}>
<ul className="chips" aria-label="Selected">
{value.map((item) => (
<li key={item}>
<span>{item}</span>
<button
type="button"
aria-label={`Remove ${item}`}
onClick={() => remove(item)}
>
×
</button>
</li>
))}
</ul>
<input
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onKeyDown={onKeyDown}
onFocus={() => setOpen(true)}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
/>
</div>
{open && filtered.length > 0 && (
<ul id={listId} role="listbox">
{filtered.map((item, i) => (
<li
key={item}
role="option"
aria-selected={i === active}
onMouseDown={(e) => {
e.preventDefault(); // keep input focus
add(item);
}}
>
{item}
</li>
))}
</ul>
)}
</div>
);
}
onMouseDown + preventDefault on options avoids input blur before click registers.
Accessibility essentials
- Each remove control named “Remove {label}”
- Combobox ARIA on the input; listbox of suggestions
- Selected chips in a list with accessible name
- Announce max reached if you block adds
- Don’t make chips the only way to remove without keyboard
Performance notes
- Filter options with
useMemofor large lists - Virtualize suggestion list if 5k+ options
- Debounce async search; abort stale requests (same as autocomplete)
Footguns
- Blur before click eats suggestion selection
- Duplicates from different casing — decide normalize rule
- Backspace deletes chip while deleting query text — only when query empty
- Comma inside freeform conflicts with paste rules
- Uncontrolled internal value desynced from parent
Interview out-loud answer
Multi-select chips are a controlled string array plus a combobox input. Enter/comma adds, backspace on empty input pops the last chip, and each chip has a named remove button. Suggestions filter remaining options with listbox keyboard support. I’d clarify freeform vs select-only early and enforce max/duplicates in one
addhelper.
Related on this site
- Autocomplete Search Box
- Custom Dropdown Select
- Typeahead Mentions
- Machine Coding Interview Framework