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.
| Question | Answer we assume | What 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. |
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?
One chip appears with the whole string as its label, comma included. If the field validates emails, the whole thing fails as one invalid address and the user must delete it and add each one by hand.
The fix is to treat paste as its own event: split the text on separators, run each piece through the same pipeline, keep the good ones as chips, and leave the bad ones in the text box with a message.
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?
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.
- 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. - Trim and collapse spaces: remove spaces at both ends and turn runs of spaces into one. " web perf " becomes "web perf".
- Check emptiness and length: drop empty strings silently; refuse anything over the limit (50 characters here) with a message.
- Mode-specific validation: an email check, a pattern like GitHub topics (lowercase letters, numbers, and hyphens), or the caller's own
validatefunction. - Build the comparison key: lowercase for case-insensitive fields. Use
toLowerCase(), nottoLocaleLowerCase(), for keys, so the key doesn't change with the user's locale (in Turkish, uppercase "I" lowercases to a dotless "ı"). - Deduplicate and cap: skip the tag if its key is already selected (or appeared earlier in the same paste), and stop once
maxTagsis 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.
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.
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 };
}
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.
2 tags added, 2 refused. Chips: CSS, React, web perf. Max 5 tags, 12 characters each.
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?
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.
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);
}
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.
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?
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.
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.
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.
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?
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.
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.
The text box and its suggestions
- The
inputhasrole="combobox",aria-expanded(true when the popup shows),aria-controlspointing at the listbox, andaria-autocomplete="list". - Focus stays in the
inputthe whole time. The highlighted option is pointed to witharia-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, liketags-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?
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.
| Event | Message in the live region | Why 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?
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.
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"andspellcheck="false"stop the keyboard from capitalizing "react" and underlining every tag.- For recipients,
inputmode="email"shows the @ key. Keeptype="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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
useTagInput controller | Holds 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 view | Renders 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 view | The input with combobox roles; reports keys, text, and paste. | Owns only DOM details like caret position and composition state. |
SuggestionList view | The listbox; plain or virtualized. | The virtualized version is a separate chunk loaded only for long lists. |
| Suggestion source | options filter or loadOptions with debounce, abort, and a per-query cache. | Shared with the autocomplete component, so race handling exists once. |
| Announcer | One polite live region per field. | Every message goes through one queue, so paste summaries don't collide with "added" messages. |
| Form bridge | Hidden 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. |
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
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 }>;
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?
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.
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 };
change argument tells the caller why the value changed, so analytics and server calls don't have to diff arrays.renderTaglets 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.validatereturns a message, not a boolean, because every refusal needs words the user can act on.creatablecan be a function, so recipients can say "create if it looks like an email".- Controlled and uncontrolled work like a native
input: passvalueto own the state, ordefaultValueto 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 samename. 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 withgetAll. And when the form resets (React 19 callsform.reset()after a successful form action), an uncontrolled tag input must listen for the form'sresetevent and go back to itsdefaultValue. - A form-associated custom element: if the design system ships web components, the element calls
attachInternals()and theninternals.setFormValue(formData), whereformDataholds many entries under the field's name. It also getsformResetCallbackfor 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).
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.
- 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.
- 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-activedescendantmust exist in the DOM, so scroll it into the rendered window before pointing at it; and give each rendered optionaria-setsizeandaria-posinsetso 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.PluralRulesor 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-startso chip spacing flips too.
Safety
- Render every label as text. React escapes text by default; the danger is reaching for
dangerouslySetInnerHTMLto 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
maxTagsis 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.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask 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 10 | Draw the wireframe: chips, text box, popup, hint, live region. | "It's two widgets in one box: a chip list and a combobox." |
| 10 to 20 | The 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 30 | Input behavior and keyboard: commit keys, IME, paste with failures, two-step Backspace, chip arrows. | "Never throw away pasted text that fails." |
| 30 to 38 | Suggestions and accessibility: async source, exclude chosen, Create row, roles, announcements. | "Focus stays in the input; the highlight uses aria-activedescendant." |
| 38 to 45 | API 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?"
It's creatable mode with a validator and a richer label. Suggestions come from contacts (local recent ones first, then the directory). Enter picks the highlighted contact; if nothing is highlighted and the draft is a valid address, it becomes a chip with the address as both key and label.
The key is the lowercased address, so "Ana@Acme.com" and "ana@acme.com" dedupe. (RFC 5321 lets the part before the @ be case-sensitive, but major mail providers treat it as case-insensitive in practice.) The parser handles "Doe, Jane" <jane@acme.com> as one item. Invalid chips can also be allowed but shown in an error style, so a user can paste 30 addresses and fix the bad ones in place; the form refuses to submit until they're fixed.
Think first
Follow-up: "Two people edit the labels on the same issue at once. What happens?"
If the form saves the whole array, the last save wins and silently drops the other person's label. Send changes instead: add and remove operations from the TagChange events, which the server applies to the current set. Two adds of different labels then both survive.
Show labels added by others through the normal live update channel, and merge them into the chips without moving focus or the caret.
Think first
Follow-up: "How do you test this?"
Unit test the pipeline with a table of inputs: spaces, case, Unicode forms, quoted commas, empty pieces, limits. Test the keyboard model with Testing Library and user-event, including paste and the two-step Backspace.
Run axe in the component tests to catch missing names and roles. Then test by hand with at least two screen reader and browser pairs, and on a real Android phone with Gboard, because the keyCode 229 behavior doesn't show up in desktop tests.
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 (isComposingis true, orkeyCodeis 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-activedescendantfor 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 makerequiredwork withsetCustomValidityon 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.
References
- WAI-ARIA Authoring Practices: Combobox pattern Roles, states, and keys for the text box and its listbox popup.
- MDN: KeyboardEvent.isComposing How to tell an IME's Enter from a real one.
- MDN: Element paste event Reading
clipboardDataand replacing the default paste. - MDN: String.prototype.normalize() Unicode normal forms, so equal-looking text compares equal.
- MDN: Intl.Segmenter Counting characters the way people see them.
- MDN: FormData.getAll() Reading many values sent under one name.
- MDN: HTMLInputElement.setCustomValidity() Plugging custom rules like "at least one tag" into native validation.
- MDN: ElementInternals.setFormValue() How a custom element submits many values with a form.
- WCAG 2.2 Understanding: Target Size (Minimum) The 24 by 24 pixel rule for small remove buttons.
- Downshift on GitHub Headless hooks for comboboxes and tag groups, a good model for a hook-first API.
- React Aria: TagGroup A tag group exposed as a grid, with keyboard removal and selection.
- MDN: input type="email" The browser's email rule and how
multiplesplits on commas.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.