Dev 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.

Highlight
Fig 1.1 · Tag Input · Dev view
Dev

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
Both

Variants, properties, states

Variants

Structurally different versions of the component.

free-textconstrainedcreatable

Properties

The same component, parameterised.

PropertyType
removableboolean
duplicatesboolean
sizesm | md | lg
maxTagsnone | low | medium | high

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
emptyfilledbusyinvalid
Both

State transitions

FromToTrigger
emptyfilledUser 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.
filledemptyUser removes the last tag (via the tag's remove button, Backspace at empty input, or programmatic clear). The placeholder reappears.
filledbusyFor 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.
busyfilledAsync suggestions return; the input shows the listbox with results.
filledinvalidUser 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"`.
invalidfilledUser clears the in-flight value or successfully commits a valid alternative. `aria-invalid` is removed.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. tagAdd
    Payload
    `{ value: string }`. Fires when a new tag is committed — by Enter, comma, blur, or programmatic add. For paste operations that split into multiple tags, fires once per tag.
    Web Components
    `tagAdd` CustomEvent on the host with `event.detail = { value }`.
    React
    `onTagAdd(value: string)` callback. React Aria's `useTagGroup` does not provide this directly; consumers wrap their own state.
    Angular Signals
    `output<string>('tagAdd')`.
    Vue
    `@tag-add` event with payload `{ value }`.
  2. tagRemove
    Payload
    `{ value: string, source: 'removeButton' | 'backspace' | 'programmatic' }`. Distinguishes how the tag was removed. The `backspace` source covers both Backspace-at-empty-input deletion and Delete on a selected tag.
    Web Components
    `tagRemove` CustomEvent with `event.detail = { value, source }`.
    React
    `onTagRemove(value, source)` callback.
    Angular Signals
    `output<{ value: string, source: TagRemoveSource }>('tagRemove')`.
    Vue
    `@tag-remove` event with payload `{ value, source }`.
  3. valueChange
    Payload
    The complete current value array. Fires after every `tagAdd` and `tagRemove`. Useful for `v-model`-style controlled-pattern or for syncing form state.
    Web Components
    `valueChange` CustomEvent with `event.detail = { value: string[] }`.
    React
    `onValueChange(value: string[])` controlled-pattern callback.
    Angular Signals
    `output<string[]>('valueChange')`.
    Vue
    `@update:modelValue` for `v-model`.
Dev

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.
Dev

Performance thresholds

  • tagCountVisibleThresholdtag-count50tags

    Above ~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-count10000characters

    Pasting >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.

Both

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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus 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

TriggerExpected
Tag addedSR 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 ArrowLeftSR announces "<value>, selected, list item, X of Y" where X is position. The selection state comes from `aria-selected="true"` on the tag.
Tag removedSR announces "<value> removed" via the live region. The tag list updates; subsequent navigation no longer encounters the removed tag.
Duplicate value rejectedSR 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-attr
  • aria-valid-attr-value
  • aria-input-field-name
  • color-contrast
  • duplicate-id-active
  • role-img-alt
Dev

Common mistakes

#taginput-no-keyboard-token-removal

Tags cannot be removed by keyboard

Problem

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.

Fix

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

Problem

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.

Fix

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

Problem

`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.

Fix

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

Problem

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.

Fix

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

Problem

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.

Fix

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.

Figma↔Code mismatches
  1. 01
    Figma

    Tags drawn as static text-with-border decorations next to an input

    Code

    Tags as live DOM elements with remove buttons, accumulated into the form value

    Consequence

    Designers 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.

    Correct

    Tags 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).

  2. 02
    Figma

    Tag remove button drawn but no keyboard navigation between tags

    Code

    ArrowLeft / ArrowRight from input navigates between tags; Backspace deletes selected tag

    Consequence

    Designers 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.

    Correct

    Document 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.

  3. 03
    Figma

    Pasted comma-separated string drawn as a single tag

    Code

    Pasted "a, b, c" splits into three separate tags by canon

    Consequence

    Designers 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.

    Correct

    Document 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]`.

  4. 04
    Figma

    Tag overflow drawn as truncating with ellipsis at the end of the input

    Code

    Tags wrap to multiple rows; the input grows in block-size to accommodate

    Consequence

    Designers 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.

    Correct

    Tags 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.