ESC

Type to search the knowledge base.

Multi Select Chips Input

Machine-coding brief for multi-select chips — tags input, keyboard remove, suggestions, a11y listbox, and controlled values.

intermediate4 min read
  • 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 options list 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)

  1. Freeform vs select-only — product rule
  2. IDs vs labels — store value ids if options have both
  3. Focus — input always the typing target; chips are buttons before it
  4. MVP — chips + freeform Enter + backspace; then suggestions
  5. 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 useMemo for large lists
  • Virtualize suggestion list if 5k+ options
  • Debounce async search; abort stale requests (same as autocomplete)

Footguns

  1. Blur before click eats suggestion selection
  2. Duplicates from different casing — decide normalize rule
  3. Backspace deletes chip while deleting query text — only when query empty
  4. Comma inside freeform conflicts with paste rules
  5. 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 add helper.

Further reading