# Build a Multi-Select with Tags

Source: https://www.techinterview.org/post/3233475207/build-multi-select-with-tags/
Updated: 2026-07-26 · 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](/companies/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.
