cf.completefrontend
My progress
SYSTEM DESIGN · #16

Design a tag (multi-select) input

A text box that collects many values as removable chips: free-form tags, picks from a list, or email recipients. The hard parts are the value model (trimming, case-insensitive duplicates, limits), the keys that commit and delete tags, pasting lists with bad items, suggestions, a keyboard and screen reader model that spans chips and a listbox, and plain form submission.

The question

Design a tag input for our design system. Teams want it for free-form labels on issues, for picking people and teams from a directory of 40,000 users, and for email recipients that must be valid addresses.

Walk me through the value it holds, which keys add and remove tags, what happens when someone pastes a list, how suggestions load, how it works with a keyboard and a screen reader, and how it plugs into forms.

Real-life examples: Topics on a GitHub repository, The To field in Gmail, Labels on a Jira issue, Tags when asking a Stack Overflow question, Multi-select in MUI Autocomplete and React Select

Requirements: what to ask before drawing anything

A tag input is a text box that collects many values. Each value you commit turns into a small pill called a chip (or a tag, or a token), sitting in the same box as the text you're typing. You've used it when adding topics to a GitHub repository, recipients in the Gmail To field, labels on a Jira issue, or tags on a Stack Overflow question.

It looks like one widget, but it is really three ideas in one box: a list of chosen values, a text box for the next value, and often a popup of suggestions. Interviewers like it because it's small enough to finish in 45 minutes yet touches data rules, a big keyboard model, accessibility, async loading, layout, and forms.

The first question decides almost everything: where do the values come from? There are three families.

  • Free-form tags: the user can type anything, and each committed string becomes a tag. Repository topics and blog post tags work like this. The danger is mess: "React", "react " and "reactjs" all end up as different tags.
  • Choose from a list: values must already exist. Assigning people, picking Jira components, or choosing countries. Typing only filters the list. This is a multi-select combobox.
  • Both, with rules: pick from a list or create a new value, sometimes only if it passes validation. Email recipients are the classic case: pick a contact, or type any address that looks valid.

Then ask the questions below. Each answer changes the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Free-form, from a list, or both?All three modes, chosen by props: issue labels are creatable, people must exist, recipients are creatable if valid.A creatable flag and a validate function on one engine, instead of three components.
How many options can suggestions search?Labels: about 200, loaded once. People: 40,000, searched on the server.Local filtering for small sets, debounced server search for big ones, and a virtualized list for long results.
How many tags per field?Usually 1 to 10. Recipients can reach 50. Some fields cap it (Stack Overflow allows 5 tags per question).A maxTags limit with a clear "limit reached" state, and a layout that copes with 50 chips.
Rules for a tag's text?Trim spaces, max 50 characters, no duplicates ignoring case. Emails must look like emails.A normalization step that runs on every commit, typed or pasted.
Do people paste lists?Yes: recipients from a spreadsheet, tags from another issue.Paste is split into many items, and some of them can be invalid.
Which devices?Desktop browsers plus iOS Safari and Chrome on Android, about 40% of sessions on phones.Commit keys must work with virtual keyboards, and phones may get a full-screen picker.
Does it live in forms?Yes, in React forms and in some plain HTML forms that post to the server.Many values under one name, required with zero tags, and validation messages.
Accessibility target?WCAG 2.2 AA, following the WAI-ARIA Authoring Practices (APG) combobox pattern.Roles for the input and suggestions, named remove buttons, and spoken updates when tags change.
Anatomy of a tag input. An issue page shows the label Labels above one bordered field. Inside the field are three chips, bug, frontend and a11y, each with a remove x; the a11y chip is armed, meaning the next Backspace removes it. On the second line of the field the user is typing per. Below the field a popup listbox shows performance as the active option, then perf-budget, then a last row Create per. A hint under it says Enter or comma adds, up to 5. Notes on the right name the armed chip, the input as a combobox that keeps focus, the active option, and the Create row.issues/42Labelsbug xfrontend xa11y xper|performanceperf-budgetCreate 'per'Enter or comma adds. Up to 5.armed chip: nextBackspace removesinput: combobox,keeps focuslistbox: activeoption markedCreate row last,only if no match
The box holds two widgets: a list of chips and a combobox. Focus stays in the text box while the user arrows through suggestions, and the chips are reached with the arrow keys from the start of the text.

Functional requirements

  • Show committed values as chips inside the field, each with a remove button, followed by a text input.
  • Commit the typed text with Enter, comma, or Tab (configurable), and split pasted text into many tags.
  • Remove tags with the chip's button, or with Backspace from an empty input.
  • Suggest matching options while typing, from a static list or an async search, hiding values already chosen.
  • Offer "Create 'foo'" when creating is allowed and nothing matches exactly.
  • Enforce rules: trimming, case-insensitive duplicates, max length, custom validation, and a max number of tags.
  • Edit a committed tag by clicking it or pressing Enter on it (free-form mode only).
  • Submit all values in forms and support required, disabled, and error messages.

Non-functional requirements

  • Accessible: every action works from the keyboard, and screen reader users hear what each key did: "React added", "React removed", "3 tags selected".
  • Responsive to typing: each key press updates the input and the local filter within one frame (about 16 ms), even with 200 local options.
  • Fast suggestions: server results appear within about half a second of the last key press (a 200 ms pause plus the request), and an old, slow response never overwrites a newer one.
  • Small: the core under about 5 KB compressed. The virtualized list and the mobile sheet load only when needed.
  • Safe: tag text is always rendered as text, never as HTML, because tags come from users.

Out of scope, and why

  • Search ranking and the search API itself: the backend owns ranking. We only call it well.
  • Rich mentions inside a text editor ("@Ana" in a comment): that's a rich text editor problem, where tags sit inside paragraphs.
  • A single-value select: covered in the dropdown select guide. We reuse its listbox ideas.
  • Managing the tag catalog (renaming a label for everyone, merging duplicates): an admin feature, not part of the input.

Check your understanding · Exercise 1

Which answers would change the design of this component in a real way? Select all that apply.

What makes a tag input hard

A first attempt usually looks like this: an array in useState, an input, and an onKeyDown that pushes the text into the array on Enter. It demos well. Then real people use it, and five things go wrong. Each later chapter answers one of them.

1. Many ways in, one set of rules

A value can arrive by typing and pressing Enter, typing a comma, pressing Tab, clicking a suggestion, choosing "Create", pasting a list, or editing an existing chip. If each path checks duplicates its own way, they drift apart. A classic bug: typed tags are trimmed and lowercased before the duplicate check, but pasted tags are not, so pasting "React, react" gives two chips. The fix is one pipeline that every path goes through.

2. The same keys mean different things

Backspace deletes a letter while there's text, and deletes a whole tag when the box is empty. Left arrow moves the caret inside the text, and moves to the chips when the caret is at the start. Enter picks the highlighted suggestion, or creates a tag from the raw text, or submits the form.

Tab moves focus, but some products also commit on Tab. So every key needs a rule that depends on where focus is, where the caret is, and whether the popup is open.

3. Two widgets share one box

The chips are a list of removable items. The text box with its suggestions is a combobox, the pattern from the autocomplete guide. Focus has to move between them in a way that keyboard users can predict and screen readers can follow. Most homemade versions make chips reachable by mouse only.

4. Changes are silent

A sighted user sees a chip appear or vanish. A screen reader user who presses Enter hears nothing at all, unless we say "React added". Same for Backspace removing a tag, a duplicate being ignored, or the limit being reached. Silent rejections are the worst: the user types, presses Enter, the text disappears, and they don't know why.

5. Phones and foreign keyboards break the obvious code

On Android, many virtual keyboards send keydown events with key set to "Unidentified" and keyCode 229 while they compose text, so a handler waiting for event.key === "," may never fire. Chinese, Japanese, and Korean users type through an IME (input method editor), where Enter confirms the chosen characters; if that Enter also commits a tag, they get half a word as a chip. The commit logic must read the text itself, not just the key.

Think first

The naive version handles only Enter in onKeyDown. What happens when a user pastes "alice@acme.com, bob@acme.com" and presses Enter?

The value model: what a tag is, and when two tags are the same

Before keys and popups, decide what the component holds. Everything else, from duplicate checks to form submission, reads this value. A weak value model leaks bugs into every later chapter.

Decision What shape should the value have?

An array of strings

value is ["react", "css"].

Duplicates are checked with includes.

Pros

  • Dead simple and easy to post in a form.
  • Fine for pure free-form tags with no server ids.

Cons

  • No room for a server id, so a label rename on the server breaks the link.
  • The text shown and the text compared are the same string, so "React" and "react" are different.
  • No place for per-tag state like "invalid" or "checking".

An array of objects with a key, a label, and an optional id

Each tag is { key, label, id? }.

key is the normalized text used for comparing; label is what the chip shows; id is the server's id when the tag exists.

Pros

  • Comparing and showing are separate, so duplicates are a Set lookup on key.
  • Works for all three modes: picked options have an id, created tags don't yet.
  • The key doubles as the React key and part of the ARIA ids.

Cons

  • Slightly more work for callers who only want strings: they map in and out.

Just the selected ids, with options looked up elsewhere

value is ["t_12", "t_91"].

Labels come from the options list.

Pros

  • Smallest value, perfect for must-exist lists.

Cons

  • Breaks for created tags, which have no id yet.
  • A chip can't render its label if the option isn't in the currently loaded page of suggestions.

What we pick: Use an array of { key, label, id? } objects. It is the only shape that serves free-form, must-exist, and creatable modes with one engine. Offer helpers so string-only callers can pass ["react"] and get strings back.

Normalization: one function decides sameness

Normalization means turning raw text into a standard form before comparing it. Every value goes through the same steps, in this order, whether it was typed, pasted, picked, or edited.

  1. Unicode form: text.normalize("NFC"). The letter "é" can be stored as one code point or as "e" plus a combining accent. They look identical but compare as different strings until normalized.
  2. Trim and collapse spaces: remove spaces at both ends and turn runs of spaces into one. " web perf " becomes "web perf".
  3. Check emptiness and length: drop empty strings silently; refuse anything over the limit (50 characters here) with a message.
  4. Mode-specific validation: an email check, a pattern like GitHub topics (lowercase letters, numbers, and hyphens), or the caller's own validate function.
  5. Build the comparison key: lowercase for case-insensitive fields. Use toLowerCase(), not toLocaleLowerCase(), for keys, so the key doesn't change with the user's locale (in Turkish, uppercase "I" lowercases to a dotless "ı").
  6. Deduplicate and cap: skip the tag if its key is already selected (or appeared earlier in the same paste), and stop once maxTags is reached.

Two finer points about the key. NFC keeps compatibility characters apart: the full-width "REACT" that some East Asian keyboards type is a different string from "REACT". NFKC, a stronger normal form, folds such look-alikes together (it also turns the ligature "fi" into "fi"). So a field can build its key from NFKC text while still showing the user's NFC text as the label.

Lowercasing is also not full case folding: "Straße" lowercases to "straße", which still differs from "strasse". Intl.Collator with sensitivity: "base" treats "a", "A", and "á" as equal, which suits matching suggestions as the user types. But it only compares two strings and produces no key for a Set, and it merges accents, so "resume" and "résumé" would count as one tag. For dedupe, a lowercased NFC or NFKC key is the predictable choice.

The value pipeline. Five steps in a row: split on separators, normalize with NFC and trimming, validate length and rules, dedupe by key, and cap at maxTags. A worked example: css is already selected, the limit is 3 and only letters, digits and hyphens are allowed. The user pastes React, vue, an empty piece, Vue, x! and go. React and vue are added, the empty piece is dropped silently, Vue is a duplicate of vue, x! is invalid, and go is over the limit. The chips become css, React, vue; x! and go go back into the text box with a message.paste: " React ,vue;;Vue,x!,go" selected: css max: 3splitseparatorsnormalizeNFC, trimvalidatelength, rulesdedupeby keycapmaxTagseach piece, after the pipelineReactvue(empty)Vuex!goaddedaddeddroppedduplicateinvalidover limitchips: css, React, vue. Back in the box: x!, go.
Typing passes one item and paste passes many, but both take the same five steps. Note that the second Vue is caught by the key added a moment earlier in the same paste.

Which spelling wins for the label? For an existing option, show the server's spelling: the user typed "javascript", the chip says "JavaScript". For a new tag, keep the user's first spelling, after trimming. If the field forces a format, like GitHub topics which must be lowercase, apply it to the label too, so the chip shows exactly what will be saved.

ts
type Tag = { key: string; label: string; id?: string };
type Rejection = { text: string; reason: string };

function commitMany(raw: string[], selected: Tag[], rules: Rules) {
  const keys = new Set(selected.map((t) => t.key));
  const added: Tag[] = [];
  const rejected: Rejection[] = [];
  for (const text of raw) {
    const label = text.normalize("NFC").trim().replace(/\s+/g, " ");
    if (!label) continue;
    const error = rules.validate(label);
    if (error) { rejected.push({ text: label, reason: error }); continue; }
    const key = rules.caseSensitive ? label : label.toLowerCase();
    if (keys.has(key)) { rejected.push({ text: label, reason: "Already added" }); continue; }
    if (keys.size >= rules.maxTags) { rejected.push({ text: label, reason: `Limit is ${rules.maxTags}` }); continue; }
    keys.add(key);
    added.push({ key, label });
  }
  return { added, rejected };
}
Every path calls this with a list: typing passes one item, paste passes many. Rejections come back with a reason the UI can show and announce.

Counting length the way people do

string.length counts UTF-16 code units, not visible characters. A family emoji made of four people joined together is one visible character but 11 code units. If the limit is for humans, count graphemes (what a person sees as one character) with Intl.Segmenter.

If the limit is for the database, count what the server counts. The real rule: use the same counting on both sides, or the client will accept tags the server refuses.

Tags that must exist versus tags you can create

In must-exist mode, typed text only filters. Enter picks the highlighted option; with no highlight, nothing is committed and the text stays. Paste is trickier: "Design, Frontend, Nope" has to be matched against the server. Send all pieces in one request (POST /api/labels/resolve), show the matched ones as chips, and leave "Nope" in the box with "No label named Nope".

In creatable mode, a new tag is only a chip until the form is saved. Don't create it on the server the moment the chip appears. A user who types "recat" by mistake and removes it would otherwise leave a junk tag in the catalog for everyone. The server creates missing tags when the whole form is saved, and runs the same normalization, because a client check can always be skipped.

Sample
Rules

2 tags added, 2 refused. Chips: CSS, React, web perf. Max 5 tags, 12 characters each.

Normalization lab with trim on, NFC on, and ignore case on. CSS is already selected. " React " has key "react" and is added; "react" has key "react" and is duplicate; "REACT " has key "react" and is duplicate; " web perf" has key "web perf" and is added.raw piecekey comparedresult_React_reactaddedreactreactduplicateREACT_reactduplicate_web___perfweb_perfaddedchips: CSS, React, web perf
Underscores stand for spaces. Turn a rule off and watch look-alike pieces turn into separate chips: the key column is what the duplicate check compares.

Check your understanding · Exercise 2

Selected tags are "React" and "CSS" with a limit of 4. The user pastes " react ,Vue,, vue , TypeScript". With case-insensitive keys, which statements are true? Select all that apply.

Input behavior: committing, pasting, deleting, editing

This chapter is about the keys and events that turn text into tags and tags back into nothing. Each rule here is small, but users notice every one of them, because they type into this box many times a day.

Which keys commit a tag

Enter is the obvious commit key. Comma is the second, because people already type lists with commas. Tab is the debated one: committing on Tab saves a key press, but Tab also means "leave this field".

A good default: Tab commits the text if there is any, and focus then moves on normally (we don't call preventDefault). With an empty box, Tab just moves focus.

Decision How do we detect that the user wants to commit?

Check event.key in keydown

On keydown, if key is Enter, , or Tab, commit and preventDefault (except Tab).

Pros

  • Simple and precise on desktop keyboards.
  • Can stop the comma from ever reaching the text.

Cons

  • On Android virtual keyboards the key is often "Unidentified", so comma never commits.
  • Fires during IME composition unless you check event.isComposing.

Watch the text in the input event

After every change, if the text contains a separator, split it: commit everything before the last separator, keep the rest as the draft.

Pros

  • Works with any keyboard, voice typing, and autocorrect, because it reads the result, not the key.
  • Shares the splitting code with paste.

Cons

  • Enter doesn't change an input's text, so it can't catch Enter or Tab. A one-line input also strips newlines from its value, so a newline only ever arrives through paste.
  • The comma flashes in the box for one frame before it's removed.

Both: keys for Enter and Tab, text for separators

keydown handles Enter, Tab, Backspace, arrows, and Escape, skipping anything while isComposing is true (and skipping Enter when keyCode is 229).

The input event handles comma and semicolon by splitting the text. Newlines and tabs come only from paste, which has its own handler.

Pros

  • Covers desktop, phones, and IMEs with one rule per job.

Cons

  • Two code paths to test, though both end in the same commitMany.

What we pick: Use both. Enter and Tab are keys, so keydown handles them; separators are characters, so the text handles them. Skip Enter while event.isComposing is true, and also when event.keyCode is 229: Safari fires the Enter that confirms an IME choice after composition has ended, with isComposing already false. With both checks, a Japanese user confirming characters doesn't get half a word as a tag.

ts
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
  if (e.nativeEvent.isComposing) return;
  const caretAtStart = e.currentTarget.selectionStart === 0
    && e.currentTarget.selectionEnd === 0;
  switch (e.key) {
    case "Enter":
      if (e.nativeEvent.keyCode === 229) return; // Safari: the IME's confirming Enter
      if (popup.activeOption) { e.preventDefault(); pick(popup.activeOption); }
      else if (draft.trim()) { e.preventDefault(); commitDraft(); }
      return; // empty draft: let Enter submit the form
    case "Tab":
      if (draft.trim()) commitDraft(); // no preventDefault: focus still moves
      return;
    case "Backspace":
      if (caretAtStart && draft === "") { e.preventDefault(); backspaceOnEmpty(); }
      return;
    case "ArrowLeft":
      if (caretAtStart && tags.length) { e.preventDefault(); focusChip(tags.length - 1); }
      return;
    case "Escape":
      if (popup.open) { e.preventDefault(); popup.close(); }
      return;
  }
}

function onInput(e: React.ChangeEvent<HTMLInputElement>) {
  const parts = e.target.value.split(/[,;]/);
  const rest = parts.pop() ?? "";
  if (parts.length) commitMany(parts);
  setDraft(rest);
}
Keys handle Enter, Tab, Backspace, and arrows; the text handles separators. Both paths end in the same pipeline.

Note the empty-draft Enter. If the box is empty and no option is highlighted, let Enter do what it does in any form: submit. Swallowing it makes the user hunt for the submit button.

Sequence diagram of committing a tag, with five columns: user, input, controller, status live region, and form. Happy path: the user types vue and presses Enter. The input calls commit with vue. The controller calls the form's onChange with vue added, tells the status region vue added, 3 tags, and clears the draft in the input. Failure path: the user types React and presses Enter. The input calls commit with React. The controller finds the key react already selected, tells the status region React is already added, and keeps the text in the input with an error message. The form is not called.userinputcontrollerstatusformvue + Entercommit(vue)onChangevue addedclear draftduplicateReact + Entercommit(React)already addedkeep text
Both paths run the same pipeline. The failure path never touches the form and never clears the text: the user hears why and can fix it.

Paste: a list in, many tags out

Listen for the paste event, read event.clipboardData.getData("text/plain"), and call preventDefault so the raw text never lands in the box. Split on commas, semicolons, newlines, and tabs (spreadsheet cells copy as tab-separated text). Insert the pasted text at the caret if the user had a partial draft, then split the combined result.

Some items will fail. The rule that matters most: never throw away what the user pasted. Turn the good items into chips, and put the items that need fixing (invalid, or over the limit) back into the text box, joined with ", ". Duplicates need no fixing, so they are only reported.

Then show one message: "2 of 30 not added: bob.acme.com (not a valid email), carol@acme.com (already added)". The user fixes the text left in the box and presses Enter. Compare that with dropping the failures silently: the user has to diff two lists by eye to find what's missing.

Keep the email check loose. A practical rule is the one browsers use for input type="email": some characters, an @, and a domain made of letters, digits, hyphens, and dots. The HTML standard openly calls this rule a "willful violation" of RFC 5322, the email format standard, because the full format is impractical for forms. Don't demand a known domain ending or refuse plus signs: only the mail server knows whether an address exists, so the check is there to catch typos, not to prove anything.

The browser's own type="email" multiple field is no shortcut here. It splits only on commas, trims spaces around each address, and has no room for display names, so a pasted list with semicolons or quoted names fails as a whole.

Backspace on an empty box

When the box is empty and the caret is at the start, Backspace has nothing to delete in the text, so it reaches for the last tag. Libraries disagree on what happens next. React Select removes the last value at once (its backspaceRemovesValue prop is on by default), and so does MUI's Autocomplete in multiple mode.

Other products, often email recipient fields, first highlight the chip and remove it on the second press. Before you quote any product in an interview, try it yourself: their behavior changes between releases.

Decision What should Backspace on an empty box do?

Remove the last tag at once

One press removes the last chip and announces "Vue removed".

Pros

  • Fastest. Matches how deleting text feels.
  • No hidden state to explain.

Cons

  • Holding Backspace to clear a long typo runs past the text and eats tags one by one, several per second with key repeat.
  • A recipient vanishes with one stray press.

Two steps: highlight, then remove

The first press marks the last chip as "armed" (highlighted, announced as "Vue, press Backspace again to remove").

The second press removes it. Any other key, a click, or typing disarms it.

Pros

  • Holding Backspace stops at the first chip instead of clearing the field.
  • The user sees what will go before it goes.

Cons

  • One more press to delete.
  • The armed state must be visible and announced, or it's a mystery.

Remove at once, offer undo

Remove the last chip and show "Vue removed. Undo" for a few seconds; Ctrl or Cmd plus Z also restores it.

Pros

  • Fast and forgiving.

Cons

  • Undo has to be reachable by keyboard and screen reader, which means a toast with focus rules, a lot of machinery for one field.

What we pick: Default to two steps, and ignore auto-repeated key events (event.repeat is true) for the removing press, so holding the key can arm a chip but never remove it. Offer backspaceBehavior="remove" for dense power-user fields. The cost is one extra key press; the saving is never losing a recipient by accident.

State machine for Backspace with three states: typing, empty box, and armed. From typing, deleting the last character goes to empty box; typing a letter goes back to typing. From empty box, Backspace arms the last chip. From armed, a second Backspace removes the chip and returns to empty box. A note says any other key, a click, or a held key with auto repeat never removes: other keys and clicks disarm, and repeats are ignored.typingdraft has textempty boxcaret at startarmedlast chip markedlast char deletedtypes a letterBackspace: arm2nd Backspace: removeother key or click: disarmheld key: ignored
The armed state is the safety catch. Holding Backspace can arm a chip but never remove it, because the removing press must have event.repeat set to false.

Editing a chip

In free-form mode, clicking a chip's label (or pressing Enter on a focused chip) turns it back into text. The simplest version removes the chip and puts its label into the draft with the caret at the end. A nicer version swaps the chip for a small inline input in the same spot, so order is kept.

Either way the edited text goes back through the pipeline, with its own old key removed from the duplicate set first, so editing "React" to "react" isn't refused as a duplicate of itself. Escape cancels and restores the original. In must-exist mode, chips aren't editable: a click on the label does nothing, and the remove button is the only action.

When the limit is reached

Don't disable the text box at the limit. A disabled input can't take focus, so keyboard users can't reach Backspace or the chips through it, and screen readers may skip it. Keep it enabled, close the suggestions and stop opening them, and change the placeholder to "Limit reached".

If the user tries to commit anyway, keep their text and say "You can add up to 5 tags. Remove one to add another." A counter like "5 of 5" linked with aria-describedby tells screen reader users the limit before they hit it.

Paste
Backspace

Enter, comma, or Tab adds. Backspace in the empty box arms the last chip. Left arrow at the start moves to the chips, where Enter edits and Delete removes.

Type a tag and press Enter, comma, or Tab.
Tag input lab. Selected, 2 of 5: React, CSS. The screen reader log, newest first: Type a tag and press Enter, comma, or Tab.selected: 2 of 5ReactCSSscreen reader says (newest first)1. Type a tag and press Enter, comma, or Tab.
The top row mirrors the chips; an armed chip is highlighted. The log shows what the live region says after each action, newest first.

Check your understanding · Exercise 3

Which of these behaviors are bugs? Select all that apply.

Suggestions: searching, excluding, creating

Suggestions turn the tag input into a combobox: a text box that controls a popup list. The autocomplete guide already covers the search machinery in depth (debouncing, cancelling stale requests, caching). Here we only cover what changes when the box collects many values.

Decision Where do suggestions come from?

A local list passed as options

The caller passes all options. The component filters them on every key press.

Pros

  • Instant: filtering 200 labels takes well under a millisecond.
  • Works offline and needs no loading state.

Cons

  • Only fits small lists. 40,000 people at about 150 bytes each is 6 MB of JSON before you type a letter.

An async loadOptions(query, { signal })

The component calls the caller's function after the user pauses for about 200 ms, passing an AbortSignal.

Results are cached per query for the life of the page.

Pros

  • Scales to any directory size.
  • The server can rank by relevance, recency, or team.

Cons

  • Needs loading, empty, and error states in the popup.
  • Responses can arrive out of order.

Both: local first, server for the rest

Show recent and frequent picks from a local list right away, then merge server results under them when they arrive.

Pros

  • The popup is never empty on focus, and the common picks need no network.

Cons

  • Merging must dedupe by key and must not move the highlighted row under the user's finger.

What we pick: Support options and loadOptions on one engine, and let the people picker use both: recent contacts locally, the directory from the server. Debounce and race handling come from the shared autocomplete code, not a second copy.

Sequence diagram of async suggestions with five columns: user, input, controller, API, and listbox. The user types re; the input reports the draft; after the debounce pause the controller sends GET q=re. The user types a; the input reports rea; the controller aborts the request for re, then sends GET q=rea. The API replies with 8 results. The controller removes values already chosen and renders the listbox.userinputcontrollerAPIlistboxtypes redraft reGET q=retypes adraft reaabort reGET q=rea8 resultsminus chosen
The stale request is aborted with its AbortController, so its answer can never overwrite the newer one. Chosen values are removed after the reply, which is why the request asks for a few extra rows.

Hiding what's already chosen

Showing "React" in the suggestions when it's already a chip invites a duplicate. Filter results by the selected keys before rendering. One trap: if the server returns 8 results and 3 are already chosen, the list shows only 5. Ask for limit + selectedCount (capped, say at 20), then trim to 8 after filtering.

Some designs keep chosen options in the list with a check mark, and picking one again removes it. MUI's Autocomplete keeps chosen options in the list by default in multiple mode, and its filterSelectedOptions prop hides them instead. It suits short fixed lists like "Status", where the list is the main UI. For long searched lists, hiding chosen values keeps the list focused on what you can still add.

The "Create" option

When creating is allowed, add a row "Create 'web perf'" at the end of the list. Show it only when the draft passes validation and no option has the same key: if the user typed "javascript" and "JavaScript" exists, there's nothing to create. The row's label should show the normalized text, so the user sees exactly what will be saved.

What does Enter do with no highlighted row? In must-exist mode, highlight the first match as the user types, so Enter picks it. In creatable mode, don't auto-highlight: Enter commits what was typed, and the pipeline still swaps an exact match for the existing option, with its id and its spelling. Otherwise a user who types "react" and presses Enter would get "react-native", just because it ranked first.

Highlighting the match

Bold the part of each option that matches the query, like "React". Split the label into pieces in JavaScript and render the matched piece in a mark element. Never build an HTML string and inject it: tag labels come from users, and a label like img src=x onerror=... in angle brackets is a script waiting to run. Match on the normalized text but slice the original label, so accents and case display as stored.

Grouping

A people picker often groups rows: "Recent", "Teams", "People". Wrap each group in an element with role="group" and aria-labelledby pointing at its heading, inside the listbox. Keep the headings out of the arrow-key order: arrows move through options only. Keep groups to 3 or 4 and each one short, or the list stops being scannable.

Check your understanding · Exercise 4

In a creatable label field, "React" is selected and the catalog has "react-native" and "Redux". The user types "re". Which suggestions should appear? Select all that apply.

Keyboard model and screen reader feedback

A tag input has three places the keyboard can be: the chips, the text box, and the suggestions. The text box is the home base, and the suggestions follow the WAI-ARIA Authoring Practices (APG) combobox pattern with a listbox popup. The chips have no official APG pattern, so we borrow from the ones that exist: a list, buttons, and a roving focus. The focus-management and keyboard-navigation lessons cover the general techniques.

Keyboard map of a tag input. The text input is the home base. Left arrow at the start of the text moves focus to the chip list, which is one Tab stop; Right arrow past the last chip or Escape returns to the input. Down arrow opens the listbox and Escape closes it, but focus stays in the input and only the highlight moves, through aria-activedescendant. Tab moves on to the next field and commits any typed text. A note lists the keys on a focused chip: Delete removes, Enter edits, Home and End jump.chip listone Tab stoptext inputhome basenext fieldlistboxhighlight onlyLeft at startRight at end, EscTabDown: open. Esc: closeon a chip:Delete removesEnter editsHome, End jump
Real focus moves only between the chips and the input. In the listbox, only the highlight moves, so the user can keep typing while choosing.

The text box and its suggestions

  • The input has role="combobox", aria-expanded (true when the popup shows), aria-controls pointing at the listbox, and aria-autocomplete="list".
  • Focus stays in the input the whole time. The highlighted option is pointed to with aria-activedescendant, so the user can keep typing while arrowing, the same technique the command palette guide uses.
  • Down and Up move the highlight, opening the popup if needed. Enter picks. Escape closes the popup; a second Escape clears the draft. Home and End move the caret in the text, as they do in any text box.
  • Each option has role="option" and an id built from its key, like tags-opt-react, so the id survives re-renders and new results. Escape spaces and symbols in the key first, since an id can't contain spaces.

Moving through the chips

Decision How do keyboard users reach the chips?

Every remove button is a Tab stop

Each chip's remove button is a normal button in the Tab order.

Pros

  • No custom key handling. Works the same for everyone.

Cons

  • With 30 recipients, reaching the next field takes 30 extra Tab presses.

One Tab stop for the chip list, arrows inside

Roving tabindex: only one chip has tabindex="0", the rest -1. Left and Right move focus between chips.

Left arrow from the start of the text box also jumps to the last chip; Right from the last chip returns to the text box.

Pros

  • Tab skips past the whole group in one step.
  • Matches how users already move through the text with arrow keys.

Cons

  • Users must discover the arrows, so add a hint to the description.

Chips reachable only through Backspace

No focus on chips at all; Backspace is the only keyboard way to remove.

Pros

  • Least code.

Cons

  • To remove the first of ten tags, you must remove all ten. Fails WCAG's keyboard requirement in spirit.

What we pick: Use one Tab stop with roving focus. On a focused chip: Left and Right move, Backspace and Delete remove it and move focus to the neighbor (or back to the text box when none is left), Enter edits in free-form mode, Home and End jump to the first and last chip, and Escape returns to the text box. The small "x" stays a real button named "Remove tag React" for pointer, voice control, and screen reader browse mode, with tabindex="-1" so it adds no Tab stops.

Libraries expose the chip group in different ways, which shows the question isn't settled. React Aria's TagGroup renders a grid with one row per tag, Downshift's useTagGroup marks the group as a listbox, and many hand-built inputs use a plain list of buttons like ours. Screen readers announce each one differently (a row, an option, a button), and support varies by screen reader. Pick one structure, use it across the design system, and test it with real screen readers.

In right-to-left languages like Arabic and Hebrew, the start of the text is on the right. The arrow that leaves the text toward the chips is Right, not Left. Read the direction from the element (getComputedStyle(input).direction) and swap the arrow meanings, or users will move the wrong way.

What a screen reader should hear

Chips appearing and vanishing are visual events. Make them spoken with a live region: a visually hidden element with role="status", which screen readers read politely after the current speech. Render it once when the component mounts and only change its text later; a live region added to the page at the same moment as its message is often not announced.

EventMessage in the live regionWhy this wording
Tag committed"React added. 3 tags."Confirms what happened and the new count in one breath.
Tag removed"React removed. 2 tags."Removal by Backspace is otherwise invisible to a non-sighted user.
First Backspace arms a chip"React. Press Backspace again to remove."Explains the two-step state the moment it starts.
Duplicate refused"React is already added."Silence would make the text seem to vanish for no reason.
Paste with failures"8 tags added. 2 not added, see message."One summary, not ten messages that interrupt each other.
Limit reached"Limit of 5 tags reached."Says why Enter stopped working.

Also give the structure meaning. The chips sit in a ul labelled "Selected tags", so screen readers announce "list, 3 items" when entering it. The input has a visible label and aria-describedby pointing at the hint ("Press comma or Enter to add. Up to 5."), the counter, and any error.

Expect differences: NVDA, JAWS, VoiceOver, and TalkBack don't read descriptions, lists, and live regions the same way, and some versions read a description only on first focus. Test with at least two screen reader and browser pairs, and treat the plan above as a starting point, not a guarantee.

Check your understanding · Exercise 5

A screen reader user tabs into the field, types "vue" and presses Enter. What should happen? Pick one.

Layout: wrapping, growing, overflowing, and phones

The field looks like one text box, but it is a flex container holding chips and an input. The outer box draws the border and focus ring, so clicking anywhere in it (between chips, in the empty space) focuses the input. Use :focus-within on the box to draw the focus ring, since the input itself has no visible border.

Wrapping chips and a growing input

Give the box display: flex and flex-wrap: wrap with a small gap. Chips keep their natural width up to a maximum (say 240 px), and long labels are cut with an ellipsis, with the full text in the remove button's name so nothing is lost for screen readers.

The input gets flex: 1 1 6rem: it takes whatever space is left on the last line, and when less than about 6 rem is left, it wraps to its own line. That beats sizing the input to its text, which leaves a tiny box at the end of a line that's hard to click.

CSS field-sizing: content can also size an input to its text, but while Chrome has had it since version 123, it reached Safari only in December 2025 (26.2) and Firefox in mid-2026 (152), so check your browser targets before relying on it.

Decision What happens when 30 recipients don't fit?

Keep growing taller

The box wraps chips onto as many lines as needed.

Pros

  • Everything is visible. No hidden state.

Cons

  • 30 chips can push the message body below the fold, which is why mail clients limit the To field's height.

A maximum height with vertical scroll

max-height of about 3 lines and overflow-y: auto; the input scrolls into view on focus.

Pros

  • Predictable layout. Every chip is still reachable.

Cons

  • A scroll area inside a form is easy to miss, and chips hidden above aren't obvious.

Collapse to one line with "+N more" when not focused

When the field isn't focused, show as many chips as fit and a "+7 more" button. On focus or click, expand to show all.

Pros

  • Dense forms and filter bars stay one line tall.
  • The full list is one click away.

Cons

  • Needs measuring chip widths. The count must be in the accessible name, like "+7 more tags".

What we pick: Collapse with "+N more" for filter bars and dense forms, and use max height with scroll for long recipient lists. Never hide chips while the field has focus: the user is editing them.

To count how many chips fit, render them all in the row once, read each chip's width, and add them up until the next one plus the "+N more" button would pass the box width. A ResizeObserver on the box reruns the count when the layout changes, such as on a window resize or a sidebar collapse. Hidden chips stay in the DOM but get the hidden attribute, so state and ids don't churn.

Two states of the same field. Left, not focused: one line with the chips React, CSS and a11y and a button +4 more; a note says a ResizeObserver recounts what fits and hidden chips keep the hidden attribute. Right, focused: all seven chips React, CSS, a11y, perf, testing, design and ux wrap over two lines with the text input on a third line; a note says every chip shows while editing, with a maximum height and then scrolling.not focusedReactCSSa11y+4 moreResizeObserver recountshidden chips: hidden attrfocusedReactCSSa11yperftestingdesignux|all chips while editingmax height, then scroll
Collapse only when the user isn't editing. The +4 more button names what it hides, and the hidden chips stay in the DOM so their ids and state don't churn.

On phones

On a phone, the virtual keyboard takes about half the screen, so a popup of suggestions under a field near the bottom has almost no room. For must-exist pickers with long lists, like assigning people, open a bottom sheet on tap: a panel that slides up from the bottom, with its own search box at the top, the results below, and the selected chips in a row at the top.

The sheet is a modal dialog, so it traps focus and returns focus to the field on close (see the modal dialog guide). Free-form tag fields usually stay inline: there is little to pick, and a sheet just adds a step.

  • enterkeyhint="enter" keeps the Enter key from showing "Go", which suggests submitting the form.
  • autocapitalize="none" and spellcheck="false" stop the keyboard from capitalizing "react" and underlining every tag.
  • For recipients, inputmode="email" shows the @ key. Keep type="text": type="email" adds browser validation that fights our own and can't hold a half-typed draft cleanly.
  • Make the remove target at least 24 by 24 CSS pixels (WCAG 2.2's minimum target size), even if the drawn "x" is smaller.

Check your understanding · Exercise 6

A filter bar shows a "Labels" tag input collapsed to one line: 3 chips and "+4 more". Which details are right? Select all that apply.

Architecture, data flow, and state

Now put the pieces together. The main idea: one controller owns every rule, and the views only render and report. This is what keeps typed, pasted, picked, and edited tags consistent: they all reach the same commitMany.

Component architecture. At the top, the views: TagField, ChipList, SuggestionList and Announcer. In the middle, the logic zone holds the useTagInput controller and the pure commitMany pipeline; the controller calls commitMany to commit. To the right, a shared suggestion source with debounce and abort sends queries to the search API below it. TagField sends events to the controller; the controller sends state to SuggestionList and messages to the Announcer. At the bottom, the controller sends tags to the form bridge, which writes hidden inputs, and calls onChange on the page or form library.viewsTagFieldChipListSuggestionListAnnouncerlogiccommitManypure pipelineuseTagInputstate + keyssuggestionsdebounce, abortpage / form libvalue, onChangeform bridgehidden inputssearch APIGET /tags?q=eventsstatemessagesfetch, signaltagsonChange
Views report and render; the controller decides. The suggestion source sits outside the controller because the autocomplete component shares it, and the pipeline is a pure function so it can be tested without a browser.
PartResponsibilityWhy the boundary is here
useTagInput controllerHolds the draft, the armed chip, the focus target, and the popup state. Runs the pipeline and calls onChange.One place for rules means one place to test them, with no DOM needed.
Value pipeline (commitMany)Split, normalize, validate, dedupe, cap. A pure function.Pure functions are easy to unit test with hundreds of cases, including paste edge cases.
ChipList viewRenders chips with roving tabindex and remove buttons. Reports remove and edit.Chips change often; keeping them separate lets each chip skip re-rendering when nothing about it changed.
TagField viewThe input with combobox roles; reports keys, text, and paste.Owns only DOM details like caret position and composition state.
SuggestionList viewThe listbox; plain or virtualized.The virtualized version is a separate chunk loaded only for long lists.
Suggestion sourceoptions filter or loadOptions with debounce, abort, and a per-query cache.Shared with the autocomplete component, so race handling exists once.
AnnouncerOne polite live region per field.Every message goes through one queue, so paste summaries don't collide with "added" messages.
Form bridgeHidden inputs, or ElementInternals for a custom element, plus setCustomValidity for required.Keeps forms working whether the page uses a form library or a plain post.
Data flow between the server and the browser. In the browser, the query cache sends GET with a query to the search API on the server, which returns rows. The query cache passes Tag objects to the controller, which sends filtered options, minus chosen tags, to the listbox. On commit the controller calls onChange to update the form value, which renders hidden inputs named labels. On save, the hidden inputs are posted to the save API, which normalizes the tags again before storing them.serverbrowsersearch APIsave APInormalize againquery cacheMap per querylistboxcontrolleruseTagInputform valueTag[]hidden inputsname=labels?q=rowsTag[]filteredonChangerenderPOST on save
Reads go through a per-query cache; writes wait for the form save. The server repeats the normalization, because the browser's checks are a convenience, not a guard.

Follow one key press through the data flow. The user types "v", TagField reports the new draft, and the controller stores it and asks the suggestion source for "v". The source checks its cache, waits for the pause, then fetches with an AbortSignal. When results return, the controller drops the selected keys and the listbox renders.

Then the user presses Enter. The controller runs the pipeline, calls onChange with the new array, and sends "Vue added. 3 tags." to the announcer, and the form bridge writes a new hidden input. The server only sees the tags when the form is saved.

Where each piece of state lives

ts
type Tag = { key: string; label: string; id?: string; meta?: unknown };

// Owned by the form (controlled) or the controller (uncontrolled)
type TagValue = readonly Tag[];

// Owned by the controller, reset on blur
type TagInputState = {
  draft: string;
  focus: { kind: "input" } | { kind: "chip"; index: number };
  armedKey: string | null;        // two-step Backspace
  editingKey: string | null;      // chip being edited inline
  popup: { open: boolean; activeKey: string | null };
  rejected: { text: string; reason: string }[];
};

// Shared by every field on the page, keyed by source + query
type SuggestionCache = Map<string, { tags: Tag[]; at: number }>;
The value belongs to the form. Everything about typing belongs to the component. The cache is shared, so two people pickers on one page don't search twice.

Why keep armedKey and editingKey as keys, not indexes? Because the array changes under them. If a paste adds tags while a chip is armed, an index would now point at a different chip, and the next Backspace would remove the wrong one. Keys stay attached to the right tag.

Check your understanding · Exercise 7

Why does the design put normalization, validation, and dedupe in one pure commitMany function? Select all that apply.

Component API and form integration

Three teams will use this: issue labels, a people picker, and email recipients. The API has to serve all three without three components, and without a prop for every whim. The component-api-design lesson covers the general rules; here's how they apply.

Decision Ship a finished component, a headless hook, or both?

A finished component only

TagInput renders everything with design system styles. Customization goes through props like renderTag.

Pros

  • Teams get correct roles and keys for free.
  • One look across the product.

Cons

  • Every unusual layout becomes a new prop request.
  • Hard to use for a different shape, like chips shown in a side panel instead of in the box.

A headless hook only

useTagInput returns state and prop getters like getInputProps() and getTagProps(index); each team renders its own markup. Downshift works this way. Version 9.2 (January 2026) added a useTagGroup hook for the chips, and its docs now mark the older useMultipleSelection as deprecated in its favor; useCombobox still drives the input.

Pros

  • Total control over markup and styling.
  • Logic is shared and tested once.

Cons

  • Every team can forget a getter and break accessibility.
  • More code at each call site.

Both, layered

The hook holds all logic. TagInput is a thin component built on the hook, used by 90% of callers. The rare custom case uses the hook directly.

Pros

  • Easy path for most, an escape hatch for the rest.
  • One engine, so fixes reach everyone.

Cons

  • Two public surfaces to document and version.

What we pick: Layer them. Most teams use TagInput; the rare custom layout uses useTagInput. Both share the pipeline, the keyboard model, and the announcer.

ts
type TagInputProps = {
  label: string;
  name?: string;                     // form field name
  value?: readonly Tag[];            // controlled
  defaultValue?: readonly Tag[];     // uncontrolled
  onChange?: (tags: Tag[], change: TagChange) => void;
  options?: readonly Tag[];          // local suggestions
  loadOptions?: (q: string, opts: { signal: AbortSignal }) => Promise<Tag[]>;
  creatable?: boolean | ((label: string) => boolean);
  validate?: (label: string) => string | null; // error or null
  maxTags?: number;
  maxLength?: number;                // default 50
  caseSensitive?: boolean;           // default false
  separators?: readonly string[];    // default [",", ";", "\n", "\t"]
  commitOnTab?: boolean;             // default true
  backspaceBehavior?: "arm" | "remove"; // default "arm"
  required?: boolean;
  disabled?: boolean;
  renderTag?: (tag: Tag, state: { armed: boolean }) => React.ReactNode;
};

type TagChange =
  | { type: "add"; tags: Tag[]; source: "type" | "paste" | "pick" | "create" }
  | { type: "remove"; tag: Tag; source: "button" | "backspace" | "delete" }
  | { type: "edit"; from: Tag; to: Tag };
The change argument tells the caller why the value changed, so analytics and server calls don't have to diff arrays.
  • renderTag lets the people picker show an avatar, but the component still wraps the result with the roles, the remove button, and the keyboard handling. Callers style chips; they can't break them.
  • validate returns a message, not a boolean, because every refusal needs words the user can act on.
  • creatable can be a function, so recipients can say "create if it looks like an email".
  • Controlled and uncontrolled work like a native input: pass value to own the state, or defaultValue to let the component own it. Switching between them during the component's life logs a warning, as React does for inputs.

Getting many values into a form

A plain HTML form only submits form controls. Chips are li elements, so they submit nothing. There are two standard fixes.

  • Hidden inputs: render one input type="hidden" per tag, all with the same name. On the server or in JavaScript, new FormData(form).getAll("labels") returns every value, in order. This works everywhere and needs no JavaScript at submit time. Two traps: Object.fromEntries(formData), a common shortcut in form actions, keeps only the last value for a repeated name, so read tag fields with getAll. And when the form resets (React 19 calls form.reset() after a successful form action), an uncontrolled tag input must listen for the form's reset event and go back to its defaultValue.
  • A form-associated custom element: if the design system ships web components, the element calls attachInternals() and then internals.setFormValue(formData), where formData holds many entries under the field's name. It also gets formResetCallback for the form's reset button.

Submit the id for existing options and the label for new ones, or post both: labels=t_12&labels=t_91&newLabels=web+perf (a form encodes the space as +). The server must not trust either: it checks that each id exists and that the user may use it, and runs the same normalization on new labels.

Required with zero tags, and error messages

The browser's built-in form checks (constraint validation) skip hidden inputs, so required on them does nothing and an empty submit goes through. The required attribute on the text box is wrong too: it would demand typed text even when tags exist.

Put the rule on the visible text box instead: when the component's required prop is set and there are no tags, call input.setCustomValidity("Add at least one label"), and clear it with an empty string once a tag exists. Now native validation blocks the submit, focuses the field, and shows the message, and form libraries that read validity see it too.

Show the same text below the field, linked with aria-describedby, and set aria-invalid="true" while it applies. The input-validation and form-state lessons cover when to show errors (on submit and on blur, not on the first key press).

Sequence diagram of submitting the form with four columns: user, form, text input, and server. Failure path: the user submits with zero tags; before sending, the form checks the validity of the text input, which answers invalid because setCustomValidity holds Add at least one label; the form focuses the input and shows the message, and nothing is sent. After the user adds bug, they submit again; the form posts labels=t_12 to the server, which replies 200 saved.userformtext inputserversubmit, 0 tagscheck validityinvalidfocus, messagesecond tryadds bugsubmitlabels=t_12200 saved
The rule lives on the visible text box through setCustomValidity, so the browser blocks the empty submit, moves focus, and shows the message for free.

Check your understanding · Exercise 8

The labels field is required and posts to a plain HTML form. Which choices work? Select all that apply.

Performance, reordering, languages, and safety

Thousands of options

A local list of 200 labels renders fine. A must-exist list of 5,000 product codes doesn't: rendering 5,000 option rows on open can take a few hundred milliseconds on a mid-range phone, and every highlight change re-renders the lot if you're careless. Two fixes, in order of value.

  1. Show fewer rows. In a long list, people usually type more letters rather than scroll far. Cap a searched list at about 50 rows with a "Keep typing to narrow 4,950 more" note. This solves most cases without any virtualization.
  2. Virtualize when the full list must be scrollable. Render only the rows in view plus a small buffer, as in the virtualized-list guide. Two combobox details matter: the option named by aria-activedescendant must exist in the DOM, so scroll it into the rendered window before pointing at it; and give each rendered option aria-setsize and aria-posinset so screen readers can say "12 of 5,000".

Filtering itself is cheap. Lowercasing and testing 5,000 short strings on each key press takes about a millisecond on a laptop. Precompute each option's normalized key once, not on every key press, and keep the draft update separate from the filter (React's useDeferredValue lets the text box stay responsive while the list catches up).

With 50 recipient chips, the risk is re-rendering all 50 on every key press in the draft. Keep the draft state out of the chip list's props, and memoize each chip on its key and armed state, so typing touches only the input.

Reordering chips (optional)

Most tags have no meaningful order, so skip reordering unless order means something, like priority in a list of reviewers. If you add drag and drop, the kanban-board guide's rule applies: every drag needs a keyboard and screen reader alternative.

On a focused chip, Alt plus Left or Right (or "Move left" and "Move right" in a chip menu) moves it one place and announces "React moved to position 2 of 4". Dragging with a mouse should start only after the pointer moves a few pixels, so a click on the chip still means edit.

Languages and separators

  • Arabic uses its own comma (U+060C), and Chinese and Japanese text often uses a full-width comma (U+FF0C) or the ideographic comma (U+3001). Add them to the default separators for those locales, or a user typing a natural comma never commits.
  • Messages like "3 tags" need plural rules: use Intl.PluralRules or the app's i18n library, since some languages have more than two plural forms.
  • Right-to-left layouts flip the chip order and the arrow keys, as covered in the keyboard chapter. Use logical CSS like margin-inline-start so chip spacing flips too.

Safety

  • Render every label as text. React escapes text by default; the danger is reaching for dangerouslySetInnerHTML to highlight matches. Build the highlight from split strings instead.
  • Treat tag text as untrusted on the server: same normalization, same length limits, and a cap on tags per request, because the client's maxTags is a convenience, not a guard.
  • Rate limit the suggestion endpoint and require a minimum query length of 1 or 2 characters for the people directory, so a script can't page through all 40,000 employees.

Check your understanding · Exercise 9

A must-exist picker has 5,000 options and uses aria-activedescendant with a virtualized list. Which statements are true? Select all that apply.

Running it in the interview

Forty-five minutes is enough for this problem only if you skip what doesn't matter. Here is a plan that fits.

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: free-form, list, or both; list size; limits; paste; forms."The source of values decides the whole design, so I'll ask that first."
5 to 10Draw the wireframe: chips, text box, popup, hint, live region."It's two widgets in one box: a chip list and a combobox."
10 to 20The value model and the pipeline: { key, label, id? }, normalize, validate, dedupe, cap."Every path, typed or pasted or picked, goes through one function."
20 to 30Input behavior and keyboard: commit keys, IME, paste with failures, two-step Backspace, chip arrows."Never throw away pasted text that fails."
30 to 38Suggestions and accessibility: async source, exclude chosen, Create row, roles, announcements."Focus stays in the input; the highlight uses aria-activedescendant."
38 to 45API and forms, then whatever the interviewer picks: mobile, virtualization, reordering."Hidden inputs with one name, and setCustomValidity for required."

Draw the wireframe first, not boxes and arrows. For a component question, the interviewer wants to see you think about the person typing. Name the parts on the wireframe, then use those names for the rest of the hour.

Think first

Follow-up: "How would you build Gmail-style recipients, where a typed name becomes a contact chip and an unknown address still works?"

Think first

Follow-up: "Two people edit the labels on the same issue at once. What happens?"

Think first

Follow-up: "How do you test this?"

Check your understanding · Exercise 10

Which moves strengthen an interview answer for this problem? Select all that apply.

Summary

  • Ask first where values come from: free-form, an existing list, or both with validation. It decides the value model, what Enter does, and how paste works.
  • Hold tags as { key, label, id? }: the key compares, the label shows, the id links to the server. Created tags are saved with the form, not when the chip appears.
  • Send every way a value arrives (typing, separators, paste, picking, editing) through one pure pipeline: Unicode-normalize, trim, validate, dedupe by key, cap.
  • Commit Enter and Tab from keydown, separators from the text itself, and never commit the Enter that confirms an IME choice (isComposing is true, or keyCode is 229 in Safari). Paste splits into many items and keeps failures in the box with a reason.
  • Make Backspace on an empty box two steps by default, and ignore key repeat for the removing press, so nobody loses a recipient by holding a key.
  • Keep focus in the input with aria-activedescendant for suggestions; make the chips one Tab stop with arrow keys inside; announce every add, remove, and refusal through one polite live region.
  • Reach forms with repeated hidden inputs under one name, and make required work with setCustomValidity on the visible input.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next