Bridge view
Tag Input
An input that accepts multiple discrete values, rendering each committed value inline as a removable token next to a text input. Distinct from Combobox by emphasising visible accumulated state (the user sees all selected values as tokens), distinct from Select-multi by rendering tokens inline rather than as a count or comma-list, distinct from plain Input by structuring multi-value semantics. Used for email-recipient pickers, label/topic editors, filter-tag composers, multi-attribute search inputs.
When to use
Use
For multi-value inputs where the user benefits from seeing all selected values inline as removable tokens — email recipient pickers, label / topic editors, filter-tag composers, multi-attribute search inputs. The visible-state feedback is the canonical advantage over Combobox-multi (count-display) and Select-multi (no-typeahead).
Avoid
For single-value selection — that is `Combobox`, `Select`, or `Input`. For free-text without multi-value semantics — plain `Input`. For multi-select where the count is sufficient and inline tokens would crowd the layout — `Combobox[multi-select]` with count display. For very long lists (50+ tags) where wrapping breaks the layout — redesign with bulk-import flow.
Versus related
- combobox
`Combobox[multi-select]` displays the selection count in the input ("3 selected") rather than as inline tokens. `TagInput` shows the tokens. Use Combobox when screen real estate is constrained or the selection count is bounded; use TagInput when the user benefits from continuously seeing what they have selected.
- select
`Select[multi]` is a fixed-list multi-picker without text input; `TagInput` may accept free text (creatable variant) and renders selected values as inline tokens. Select is for bounded fixed lists; TagInput is for open-ended or visible-accumulation use cases.
Figma↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 01 Figma
Tags drawn as static text-with-border decorations next to an input
CodeTags as live DOM elements with remove buttons, accumulated into the form value
ConsequenceDesigners may treat tags as visual decoration. Implementations following the Figma file ship tags as styled spans with no remove affordance and no keyboard contract; users cannot remove tags except by retyping the entire value, and keyboard users cannot remove at all.
CorrectTags are interactive elements with remove buttons; the tag-list is part of the form value, not decoration. The Figma component must encode the remove affordance and the removable state; the canonical reference documents the keyboard contract (Backspace at empty input deletes last tag).
- 02 Figma
Tag remove button drawn but no keyboard navigation between tags
CodeArrowLeft / ArrowRight from input navigates between tags; Backspace deletes selected tag
ConsequenceDesigners draw the remove × on each tag without considering keyboard navigation. Developers shipping mouse-only remove break a11y for keyboard users — they must Tab through every tag's remove button, with no "select tag, then delete" canonical pattern.
CorrectDocument the canonical keyboard model: ArrowLeft from empty input selects the last tag (visual highlight), ArrowLeft / ArrowRight navigate between selected tags, Backspace / Delete removes the selected tag. The remove buttons remain mouse-targets; keyboard users use the navigation pattern.
- 03 Figma
Pasted comma-separated string drawn as a single tag
CodePasted "a, b, c" splits into three separate tags by canon
ConsequenceDesigners compose mock pastes as single tags; developers following the design ship literal-paste behaviour. Users pasting from spreadsheets or comma-separated lists get a single tag containing the entire pasted string; retyping each tag manually defeats the input's purpose.
CorrectDocument the canonical paste-split behaviour: pasted text containing commas, semicolons, or newlines splits into multiple tags on commit. Splitter characters are consumer-configurable but the default is `[,;\n]`.
- 04 Figma
Tag overflow drawn as truncating with ellipsis at the end of the input
CodeTags wrap to multiple rows; the input grows in block-size to accommodate
ConsequenceDesigners draw tag overflow as horizontal-truncate (the input has fixed inline-size and tags clip). Implementations following the design hide tags beyond a count; users cannot see what they have selected. Or developers ignore the design and ship multi-row wrap; the design and production diverge.
CorrectTags wrap naturally to multiple rows; the input grows in block-size. The wrapped layout is canonical because tags represent committed values that must remain visible. For space-constrained contexts, document `maxTags` plus a "+N more" affordance — but never silent clipping.
Variants, properties, states
Variants
Structurally different versions of the component.
free-textconstrainedcreatable Properties
The same component, parameterised.
| Property | Type |
|---|---|
removable | boolean |
duplicates | boolean |
size | sm | md | lg |
maxTags | none | low | medium | high |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | emptyfilledbusyinvalid |
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps free-text / constrained / creatable. |
Size | Variant | size | sm / md / lg. |
Removable | Boolean | removable | Toggles the per-tag remove button slot. Read-only inputs (display historical tags) set false. |
Duplicates | Boolean | duplicates | Default false (reject duplicates). True for use cases where the same value may legitimately appear multiple times (rare; typically false). |
Max Tags | Variant | maxTags | none / low / medium / high. Drives the "+N more" affordance threshold and surfaces an inline error when the user attempts to exceed. |
Has Suggestions | Boolean | suggestions | For constrained / creatable variants. Toggles the suggestion listbox below the input. |
Placeholder | Text | placeholder | — |
Tag Label | Text | tagLabel | Per-tag content; typically the same as the underlying value but may differ (e.g. value=user-id, label=display-name). |
Tag Icon | Instance Swap | tagIcon | Optional leading icon per tag (avatar for user-tags, color swatch for label-tags). |
State transitions
| From | To | Trigger |
|---|---|---|
empty | filled | User commits the first tag — by typing into the input and pressing Enter, comma, or losing focus (consumer- configured commit triggers). The placeholder disappears; the tag-list begins rendering tokens. |
filled | empty | User removes the last tag (via the tag's remove button, Backspace at empty input, or programmatic clear). The placeholder reappears. |
filled | busy | For constrained / creatable variants with async suggestions, the user types; the suggestion request is in flight. The input shows a busy indicator; the tag list is unaffected. |
busy | filled | Async suggestions return; the input shows the listbox with results. |
filled | invalid | User attempts to commit a value that violates a constraint (already-present in `duplicates: false` mode, exceeds maxTags, fails a custom validator). The input surfaces an inline error and sets `aria-invalid="true"`. |
invalid | filled | User clears the in-flight value or successfully commits a valid alternative. `aria-invalid` is removed. |
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
root | frame | Auto-layout horizontal frame; wraps tags plus input across multiple rows when overflowing |
tag | instance | Tag/chip component instance with optional remove button; size and color from parent input |
tag-remove | instance | Icon button instance inside the tag; visibility bound to "removable" property |
input | instance | Text input component instance; min-width drives input visibility when many tags present |
placeholder | text | Placeholder text style; only visible in zero-tag empty-input state |
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
root | root | composite-form-control |
tag | tag | list-item |
tag-remove | tag-remove | button |
input | input | textbox |
placeholder | placeholder | presentational |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-tag-input>` host wrapping a list of `<ui-tag>` children plus an internal `<input>`; light DOM for tag content; events fire on the host | attributes (`variant="constrained"`, `removable`, `duplicates`, `size="md"`, `max-tags="medium"`); `data-state="empty|filled|invalid|busy"` for CSS |
| React | React Aria `useTagGroup` plus a paired text input; or third-party libraries (react-tag-input, downshift composed with custom tag rendering); Radix does not ship a TagInput primitive as of 2026-04 | props with class-variance-authority for variant / size; `removable`, `allowDuplicates` boolean props; controlled or uncontrolled value (string array) |
| Angular (signals) | Angular Material `MatChipGrid` / `MatChipRow` plus `<input>`; or Angular CDK `cdk-listbox`-based custom composition | input<'free-text' | 'constrained' | 'creatable'>(); `[removable]`, `[duplicates]` host bindings; `output<string[]>('valueChange')` |
| Vue | Headless UI does not ship TagInput as of 2026-04; third-party (vue-tags-input, primevue Chips) or custom composable around an `<input>` plus `<ul>` of tag components | defineProps with literal-union types; `:variant`, `:removable`, `:duplicates` props |
Events
tagAddtagRemovevalueChange
Form integration
- name attribute
- The internal `<input>` carries the form `name` attribute when free-text variant is used and the input value should contribute (rare); more commonly the consumer wires a hidden `<input type="hidden">` per tag with the same `name`, matching `<select multiple>` native behaviour. Or the consumer wires form-state via React Hook Form / Vue v-model.
- FormData serialization
- Multi-value commits as multiple FormData entries with the same key (one entry per tag), matching `<select multiple>` and `<input multiple>` native behaviours. The canonical serialization is `value[]=tag1&value[]=tag2&value[]=tag3` or `value=tag1&value=tag2&value=tag3` depending on backend expectation. Avoid comma-joining tags into a single FormData entry — backends would have to re-split.
- form.reset()
- `form.reset()` restores the tag list to its `defaultValue` (typically empty). Selected-tag highlight clears; input value clears; `aria-invalid` clears. Consumers managing framework state outside the DOM need an explicit reset hook (`onReset` listener on the form syncs).
- HTML5 validation
- `required: true` triggers HTML5 validation when the tag list is empty (canonical via the inner input's `required`). `maxTags` violations use `setCustomValidity()` plus `aria-invalid` on the input. The inline-error pattern surfaces user-facing messages via `aria-describedby`. For constrained variant, custom validators per-tag (e.g. "must be valid email") are consumer-configured; failed tags are not committed and surface inline error.
Performance thresholds
tagCountVisibleThresholdtag-count≥50tagsAbove ~50 visible tags, render performance degrades (each tag is a DOM subtree with its own remove button) and visual scannability collapses. For inputs that legitimately hold many tags (a CRM contact's labels across years), implement a "+N more" affordance with modal/popover expansion of the full list, virtualize the tag list, or paginate. Below 50, render all visible.
pasteSplitBudgetpaste-character-count≥10000charactersPasting >10k characters (a CSV column dump) into TagInput is a misuse signal — likely the user wants a bulk-import flow rather than per-tag entry. Above the threshold, surface a "Paste detected — open bulk import?" prompt instead of attempting to split into tags. Below the threshold, split-and-validate inline.
Internationalisation
RTL · mirroring
Tag-list inline order reverses logically — first-added tag appears at the inline-start (visual right in RTL). ArrowLeft / ArrowRight keyboard navigation follows logical direction (ArrowLeft moves toward inline-start in both LTR and RTL; canonical model; in RTL contexts SR users may experience this as "moving toward the visual right", which is correct because tag order is also visually- right-first). The remove button on each tag moves to the inline-end of the tag (visual left in RTL).
Text expansion
Tag labels grow with translation; tags wrap to additional rows naturally. Long-text labels (DE / RU / FI) may force wider tags than designed; allow tag inline-size to grow intrinsically. Avoid fixed tag widths. The placeholder text expansion follows Input's expansion rules; consider longer placeholders ("Add tag…" → "Schlagwort hinzufügen…") forcing the input to a minimum inline-size sufficient to show full placeholder.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
root | Root is a layout container; no required ARIA role. The focusable element inside is the `<input>` — clicking the root delegates focus to the input. Wrap with a `<label>` or pair via `aria-labelledby` so SR users hear a name when focus enters. | |
tag | Each tag is a list item inside an implicit list (the tag-list region of the input). For SR users, the tag's accessible name is its label; the remove affordance is an additional button labelled "Remove <tag label>". Tags themselves are not buttons — clicking the tag focuses or selects it, depending on consumer choice; clicking the remove affordance removes the tag. | |
tag-remove | Real `<button>` with accessible name "Remove <tag label>" (composed via aria-label). Activation removes the tag and moves focus to the next tag (or to the input if it was the last tag). Tab from the input lands here only when the input is empty and Backspace navigation has selected the tag for removal. | |
input | For free-text variant, plain `<input type="text">`. For constrained / creatable variants, the input is wired as a Combobox (`role="combobox"`, `aria-controls` referencing the suggestion listbox, `aria-activedescendant` for the highlighted suggestion). The accessible name comes from the wrapping label; the input's value is the in-flight text only — committed values live in the tag list. | |
placeholder | Placeholder is not the input's accessible name (the wrapping label is). Avoid placeholder-only labelling — when typing or after committing tags the placeholder disappears and SR users have nothing to identify the field from. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus enters the input. The tag list itself is not in the tab order (tags are reached via ArrowKeys from the input, not via Tab). Tab from the input moves to the next document focusable. |
Enter or comma (focus on input with non-empty value) | Commits the current input text as a tag. Input clears; focus stays on input ready for next typing. Comma may be replaced by other separator characters per consumer configuration (semicolon, newline). |
Backspace (focus on input, input empty) | First press: selects the last tag (visual highlight via `aria-selected` or `data-selected`). Second press: removes the selected tag. Some implementations remove on first Backspace (faster but accident-prone); canonical APG-aligned behaviour is two-step. |
ArrowLeft (focus on input, input empty) | Selects the last tag. Subsequent ArrowLeft moves selection to the previous tag; ArrowRight moves toward the input. |
Delete (selected tag) | Removes the selected tag immediately. Focus moves to the next tag (or to input if last tag removed). |
Escape (selected tag) | Deselects the tag without removing. Focus returns to the input. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Tag added | SR announces "<value> added" via a polite live region (`aria-live="polite"` on a hidden status element). The tag-list itself is announced as a list when navigated; the live region carries the transient confirmation. |
| Tag selected via Backspace or ArrowLeft | SR announces "<value>, selected, list item, X of Y" where X is position. The selection state comes from `aria-selected="true"` on the tag. |
| Tag removed | SR announces "<value> removed" via the live region. The tag list updates; subsequent navigation no longer encounters the removed tag. |
| Duplicate value rejected | SR announces "<value> already added" via the polite live region; `aria-invalid="true"` set on the input briefly. The rejection is audibly explicit, not silent. |
axe-core rules to assert
aria-required-attraria-valid-attr-valuearia-input-field-namecolor-contrastduplicate-id-activerole-img-alt
Common mistakes
#taginput-no-keyboard-token-removal
Tags cannot be removed by keyboard
Each tag has a × button reachable only via Tab through the entire tag list, plus there is no Backspace-at- empty-input canonical removal. Keyboard users must laboriously Tab through every tag's remove button; the a11y experience is significantly worse than mouse.
Implement the canonical keyboard model: Backspace at empty input selects (highlights) the last tag without removing; second Backspace removes it. ArrowLeft from empty input also selects the last tag for navigation. Once a tag is selected, ArrowLeft / ArrowRight navigate between tags; Backspace / Delete remove the selected tag.
#taginput-paste-no-split
Pasting comma-separated text creates a single tag
User pastes "alice@x, bob@y, carol@z" expecting three tags; gets one tag containing the entire string. Spreadsheet imports and quick-fill flows break.
On paste, split the pasted text on canonical separator characters (`,`, `;`, newline by default) and commit each segment as a separate tag. Validate each segment individually; segments that fail validation surface as a single "X invalid entries" error rather than aborting the entire paste.
#taginput-duplicates-silent
Duplicate tags accepted silently
`duplicates: false` is set but committing an already- present value silently no-ops. The input clears, but no error is shown; users do not know their attempted addition was rejected.
On duplicate-rejection, surface inline-error feedback: the input briefly highlights with an error tone, `aria-invalid="true"` is set, and a hidden status region announces "<value> already added". Avoid silent rejection; document the user-feedback contract canonically.
#taginput-no-aria-on-tags
Tags rendered as styled `<span>`s with no SR label structure
Tag list is a `<div>` of styled spans. SR users hear everything as plain text; the structure (this is a list of values, this is a remove button) is invisible. Worse, tag count and "X of Y selected" are not announced.
Render the tag list as a `<ul>` (or `<div role="list">`) with each tag as `<li>`. Each tag's accessible name is its label; the remove button is a real `<button>` with `aria-label="Remove <label>"`. SR navigates by list-item structure and hears each tag plus the remove option.
#taginput-tokens-clipped
Tag overflow silently clipped at input edge
Input has fixed inline-size; tags accumulate horizontally and clip at the inline-end. Users cannot see what they have committed beyond the visible tags; SR users hear everything but pointer users with vision rely on what they see.
Tags wrap to multiple rows by canon; the input grows in block-size. For dense layouts, document `maxTags` plus a "+N more" affordance — never silent clipping. Mature primitives (Radix, React Aria) wrap by default; implementations should not override.