Multi-select inputs (with selected items rendered as removable tags) appear everywhere — Gmail, Slack, Notion, every signup form with multi-tag categorization. The interview tests whether you understand the combobox pattern, keyboard interactions, and accessibility.
Functional requirements
- Type to filter options — narrow the visible options to matches as the user types. Interviewers watch whether you filter on every keystroke and whether matching is case-insensitive and substring-based rather than prefix-only.
- Select option → adds tag — clicking or pressing Enter on an option moves it into the tag list and clears the input so the user can keep adding. Decide up front whether an already-selected option disappears from the dropdown or stays visible but disabled.
- Tag has X to remove — each tag needs a clickable remove button with an accessible label like
aria-label="Remove React"so screen readers announce which tag gets deleted. - Backspace at empty cursor removes last tag — when the input is empty, Backspace should delete the most recently added tag, a small touch experienced reviewers look for. Some implementations require two presses (first highlights the tag, second removes it) to avoid accidental deletion.
- Keyboard navigation (arrow keys, Enter) — the whole component should be operable without a mouse: arrows move the highlight, Enter selects, Escape closes. This is often the single biggest signal in the interview.
- Optionally creatable (allow new tags not in list) — let the user turn free text into a new tag when nothing matches. Clarify with the interviewer whether new values are allowed, since it changes both validation and any async-save path.
Architecture
Three pieces:
- Input field for typing — a single text input drives both filtering and tag creation, usually sitting inline with the tags so the caret appears right after the last pill.
- Tag list (selected items) — renders the picked items as pills, typically laid out with flex-wrap so they flow onto multiple lines as they accumulate.
- Dropdown of options matching input — a floating listbox of filtered matches, positioned below the input (or above, if space is tight) and toggled by focus and typing.
State
- Selected tags (array) — the source of truth for what the user picked; store stable IDs, not display labels, so renames and duplicates behave correctly.
- Input text — the current query string, controlled so you can clear it after each selection.
- Filtered options (derived) — compute this from the option list plus the input rather than storing it, so it can never drift out of sync; memoize it if filtering is expensive.
- Highlighted option index — tracks which option the keyboard has focused; reset it to the top (or -1) whenever the filtered list changes so the highlight never points at a stale row.
The ARIA combobox pattern
Modern WAI-ARIA pattern (revised in 1.2):
- Wrapper has
role="combobox"— this tells assistive tech that the input owns a popup; the 1.2 revision moved the role onto the input itself rather than a wrapping div. - Input has
aria-controlspointing to listbox — connects the input to the popup it drives, referencing the listbox’sid. - Input has
aria-expanded— toggle it betweentrueandfalseas the dropdown opens and closes so screen readers announce the state. - Listbox has
role="listbox"— wraps the options so they are exposed as a single selectable group. - Each option:
role="option",aria-selected— mark options that are already chosen as selected so their state is announced. - Active descendant via
aria-activedescendant— keeps DOM focus on the input while pointing at the highlighted option’sid, so typing keeps working as the user arrows through the list.
Keyboard interactions
- Down: open dropdown / next option
- Up: previous option
- Enter: select highlighted option
- Escape: close dropdown
- Backspace at empty cursor: remove last tag
- Tab: typically closes the dropdown
Filtering
Client-side: options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
For large option lists (1000+): server-side with debounced API call.
Async option loading
For tag types like “people in your org,” fetch on each keystroke:
- Debounce 200–300ms — wait for a pause in typing before firing the request so you are not querying on every character.
- Cancel previous request — abort in-flight requests with an
AbortControllerso a slow earlier response can’t overwrite results for a newer query, a classic race condition interviewers probe. - Show loading indicator — give feedback while fetching, and distinguish “loading” from “no results” so an empty dropdown isn’t mistaken for a finished empty search.
- Cache recent results — keep a small map of query → results so repeat searches and backspacing feel instant.
Creatable tags
“Add new” tag option when user types text not in the list. Common pattern:
{filteredOptions.length === 0 && query && (
<Option onClick={() => createTag(query)}>
Create "{query}"
</Option>
)}
Tag rendering
- Pill shape with text + remove button — give each pill enough padding and a distinct background so it reads as one unit, and give the remove button its own focus and hit target rather than a bare X glyph.
- Truncate long text with tooltip on hover — cap pill width and use ellipsis for long labels, exposing the full text via a
titleattribute or tooltip so nothing is permanently hidden. - Different colors for tag categories (optional) — if you color-code, never rely on color alone; pair it with a label or icon so colorblind users can still tell categories apart.
Mobile
- Tap to focus input — tapping anywhere in the control should focus the input and raise the keyboard, so make the whole pill area the tap target, not just the narrow input.
- Tap option to select — options need finger-sized hit areas (44px is the widely cited minimum) since precise pointing is harder on touch.
- Swipe left on tag to delete (some apps) — a swipe-to-remove gesture is a nice extra, but keep the visible X too, because gestures are undiscoverable on their own.
- Long-press for context menu — a long-press can surface actions like edit or duplicate, though this is a bonus rather than something the interview expects.
Async creation
For tags that need server creation (custom labels):
- Optimistic add (show tag immediately)
- API call in background
- If creation fails, remove the tag and show error
Common mistakes
- No keyboard navigation
- Backspace does not remove last tag
- aria-activedescendant set incorrectly (focus jumps when typing)
- Filtering blocks main thread for large option lists
- No loading state for async options
Library options
- Downshift: headless combobox, full control
- react-select: popular, full-featured
- cmdk: command palette / combobox built for Linear-style UI
- Radix Combobox: in 2024+
Frequently Asked Questions
Should I use react-select or build from scratch?
react-select for production speed. Build from scratch for interview practice or unusual customization.
How do I handle tags with special characters?
Escape display; preserve raw value in state. Validate before allowing creation.
What if the user is offline?
Cache options locally. Allow creation, queue for sync.
Useful next steps:
