Build a Multi-Select with Tags

Updated · techinterview.org

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-controls pointing to listbox — connects the input to the popup it drives, referencing the listbox’s id.
  • Input has aria-expanded — toggle it between true and false as 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’s id, 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 AbortController so 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 title attribute 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.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack
Scroll to Top