AniUI Academy

Build a searchable multi-select

mediumUI Components+210 XP

Build a multi-select with a text input and a list of selectable options. Typing filters the options (case-insensitive substring match on the option label).

Clicking an option toggles it in the current selection. Selected options appear as chips above the input; clicking a chip's × removes that selection.

The dropdown stays open while interacting. Clicking an option does not clear the search box.

Examples

// Initial: no chips, all 7 options with aria-selected="false".

// Type "m" → only Marketing + Operations shown.

// Click opt-marketing
// → chip-marketing appears above the input;
//   opt-marketing aria-selected="true"; query stays "m".

// Click chip-remove-marketing
// → chip gone; opt-marketing aria-selected="false".

Options stay visible while picking; clicking them toggles rather than closes.

function toggle(label) {
  setSelected((prev) => {
    const next = new Set(prev);
    if (next.has(label)) next.delete(label);
    else next.add(label);
    return next;
  });
}

// On <li aria-selected={selected.has(label) ? "true" : "false"} />

A `Set` makes membership and toggle O(1); always copy before mutating so React sees a new reference.

What it has to do

  • The input is `<input data-testid="ms-input" />`.
  • The dropdown is `<ul data-testid="ms-options">`, present when there is at least one filtered option.
  • Each option is `<li data-testid="opt-{slug}" aria-selected="true|false">` — slug is a lowercase hyphenated version of the label.
  • Each selected chip is `<li data-testid="chip-{slug}">` with a `<button data-testid="chip-remove-{slug}">×</button>`.
  • Clicking an unselected option adds it to selection; clicking a selected option removes it. The dropdown stays open across clicks.
  • Removing a chip via its × restores the option to `aria-selected="false"` in the dropdown.

Notes

  • Keep the dropdown open on option click — users expect to pick multiple without reopening. Close only on explicit outside click or Escape (not graded here, but production-expected).
  • Preserve the query across selections so a user can refine ("m" → pick Marketing → pick Operations) without retyping.
  • `role="listbox"` + `aria-multiselectable="true"` on the `<ul>`, with each `<li role="option">`, is the correct ARIA pattern — the test rig only checks `aria-selected`, but production needs the roles too.
  • For a very large option list, debounce the filter or use a `Trie` / fuzzy-ranker — a straight `includes` on each keystroke is fine for 100 options and painful for 10,000.
Asked atShopifyLinearAtlassianLinkedInDatadog

Stuck? The react course covers the ideas this problem is built on.

import { useState } from "react";

const LABELS = ["Design", "Engineering", "Marketing", "Operations", "Product", "Sales", "Support"];

function toSlug(label) {
  return label.toLowerCase().replace(/\s+/g, "-");
}

export default function App() {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(new Set());

  // TODO: compute filtered options; implement toggle on option click;
  // implement remove on chip × click.
  const filtered = LABELS;

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, maxWidth: 320 }}>
      <ul style={{ display: "flex", flexWrap: "wrap", gap: 4, listStyle: "none", padding: 0, marginBottom: 8 }}>
        {[...selected].map((label) => (
          <li key={label} data-testid={`chip-${toSlug(label)}`} style={{ border: "1px solid #ddd", borderRadius: 999, padding: "2px 8px" }}>
            {label}{" "}
            <button data-testid={`chip-remove-${toSlug(label)}`}>×</button>
          </li>
        ))}
      </ul>
      <input
        data-testid="ms-input"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search teams…"
        style={{ width: "100%", padding: 8 }}
      />
      {filtered.length > 0 ? (
        <ul data-testid="ms-options" style={{ listStyle: "none", padding: 0, border: "1px solid #ddd", marginTop: 4 }}>
          {filtered.map((label) => (
            <li
              key={label}
              data-testid={`opt-${toSlug(label)}`}
              aria-selected="false"
              style={{ padding: 8 }}
            >
              {label}
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  );
}

5 tests · +210 XP

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.