# Tag Input

> A React tag input that turns typed, pasted or suggested text into removable chips, for tags, keywords and email recipients.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add tag-input`
- Docs: https://wingo-ui.com/components/tag-input

## Interaction

Type a word and press Enter, a comma or a semicolon and it becomes a chip that slides in, with the caret moving along after it. Paste a list like emma@acme.com, michael@acme.com; olivia@acme.com and every entry becomes its own chip, duplicates once. A word that fails validation stays in the field, the text shakes and the reason shows as the field error until you change it; typing a tag that already exists makes the existing chip pulse instead of adding it again. With suggestions a list opens under the field as you type, matches in bold and diacritics ignored: Arrow Down and Up move through it, Enter picks the highlighted one, Escape closes it. Backspace in the empty field puts the last tag back as text to edit it (select mode highlights it first and a second Backspace removes it; remove mode deletes it at once). Arrow Left at the start of the text moves to the chips: Left and Right walk between them, Home and End jump, Delete or Backspace removes the focused chip and focuses its neighbour, Enter or F2 (or a double-click, or a second tap on a focused chip) edits it in place where Enter saves and Escape cancels, and Escape or Right past the last chip returns to the text. Cmd/Ctrl+Enter adds the text and submits the form. Every chip has an × to remove it and clearable adds a Clear all button; additions and removals are read out politely. At the maximum the text field hides and the description says the limit is reached. On a phone the text is 16px so iOS never zooms, the keyboard shows an Enter key (Done at the limit), commas typed on Android keyboards still split tags, the × buttons have a large touch area and the suggestions stay in the page under the field, above the keyboard.

## Installation

```bash
npx wingo-ui@latest add tag-input
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/tag-input` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/tag-input.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { TagInput } from "@/components/ui/tag-input"

export function ListingTags() {
  const [tags, setTags] = useState(["bike", "vintage"])

  return (
    <TagInput
      label="Listing tags"
      description="Up to 8 tags, separated by commas."
      placeholder="Type and press Enter"
      value={tags}
      onValueChange={setTags}
      max={8}
      name="tags"
      suggestions={[
        { value: "bike", label: "bike" },
        { value: "vintage", label: "vintage" },
        { value: "trek", label: "trek" },
        { value: "city", label: "city" },
        { value: "sport", label: "sport" },
      ]}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the box: the label above it, linked to the text input; the box is a group named by it. |
| `description?` | `ReactNode` | `""` | Helper text under the box, linked with aria-describedby. At the limit it is replaced by labels.limit(max) when you pass none. |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the field invalid. Rejected tags show their own message here until the text changes. |
| `required?` | `boolean` | `false` | The red mark after the label and aria-required on the text input. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the field, blocks typing, hides the × on chips and disables the hidden inputs. |
| `readOnly?` | `boolean` | `false` | Chips stay visible and focusable but cannot be removed, edited or added to; no suggestions. |
| `placeholder?` | `string` | `"Add a tag"` | Hint text while there are no tags; the input stays usable after. The demo passes Add a tag. |
| `value?` | `string[]` |  | The tags, controlled; pair it with onValueChange. A tag that equals a suggestion's value shows that option's label and icon. |
| `defaultValue?` | `string[]` | `"[]"` | The starting tags while uncontrolled; a form reset returns to them. |
| `onValueChange?` | `(tags: string[]) => void` |  | Fires with the new list after every add, remove, edit and clear. |
| `suggestions?` | `ListboxItems (TagInputSuggestion \| TagInputSuggestionGroup)[]` |  | Autocomplete options filtered while typing (case and diacritics ignored, groups kept). Options already added leave the list. A picked option adds its value; typing an option's label exactly adds its value too. |
| `delimiters?` | `string[]` | `"[\",\", \";\"]"` | Characters that add the current text as a tag, detected in the text itself so Android keyboards work. Enter always adds. |
| `validate?` | `(tag: string, tags: string[]) => (string \| boolean)` |  | Runs after transform. false rejects with labels.invalid(tag), a string rejects with that message; the text stays in the field and shakes. tagValidators.email, .url and .hashtag are exported; with the email one the keyboard switches to email. |
| `transform?` | `(tag: string) => string` | `"trim + collapse spaces"` | Normalizes typed text before validation, e.g. lowercase and a leading #. The default trims and collapses spaces, which also runs after yours. |
| `labels?` | `Partial<TagInputLabels>` |  | Screen reader copy and messages, merged over the English defaults: tag(tag) the chip's name ("Tag bike, press Delete to remove"), remove(tag), added(tag), removed(tag), duplicate(tag), limit(max), invalid(tag), notInList(tag), clear ("Clear all"), suggestions ("Suggestions"). |
| `onReject?` | `(tag: string, reason: "invalid" \| "duplicate" \| "limit" \| "notInList") => void` |  | Called for every tag that was turned away, e.g. to show your own message when the control sits inside a <Field>. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Minimum height 32 / 40 / 48px (md 44px on touch) with 20 / 24 / 28px chips and 13 / 14 / 15px text (16px on phones). The box grows as chips wrap. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The box look shared with every input. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Box corners scaled with the size; chips follow (pill chips in a full box, square chips with none, 6px otherwise). |
| `chipVariant?` | `"soft" \| "outline" \| "solid"` | `"soft"` | The Badge variant of the chips. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Semantic color of the chips. |
| `color?` | `string` | `""` | Any CSS color for the chips; replaces the tone. Empty uses the tone. |
| `max?` | `number` | `0` | Maximum number of tags. At the limit the text field hides, the keyboard shows Done and the description reads labels.limit(max). 0 means no limit. |
| `maxLength?` | `number` | `0` | Characters per tag; longer text is cut as you type or paste. 0 means no limit. |
| `backspace?` | `"edit" \| "select" \| "remove"` | `"edit"` | Backspace in the empty field: edit puts the last tag back as text, select highlights it first and a second press removes it, remove deletes it at once. |
| `editable?` | `boolean` | `true` | Double-click a chip, tap a focused chip, or press Enter or F2 on it to edit it in place. Emptying it removes the tag. |
| `addOnBlur?` | `boolean` | `true` | Leaving the field adds what was typed (validated like Enter). |
| `addOnPaste?` | `boolean` | `true` | A pasted text with delimiters, new lines or tabs becomes several tags at once; off, it is pasted as plain text. |
| `allowDuplicates?` | `boolean` | `false` | Off, a tag that already exists (compared without case or diacritics) is not added and the existing chip pulses. |
| `restrictToSuggestions?` | `boolean` | `false` | Only suggestions can become tags: the first match is highlighted as you type and other text is rejected with labels.notInList. |
| `openOnFocus?` | `boolean` | `false` | Opens the suggestions as soon as the field is focused instead of when you type or press Arrow Down, and keeps them open after a pick. |
| `clearable?` | `boolean` | `false` | A Clear all × at the end of the box while there are tags; focus returns to the text. |
| `name?` | `string` | `""` | One hidden input per tag with this name, so FormData.getAll(name) returns the list. The demo uses tags while empty. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `id?` | `string` |  | The text input's id. Defaults to useId(); inside a <Field> the Field's id wins. |
| `ref?` | `RefObject<HTMLInputElement \| null> or (node) => void` |  | The text input. Every other native prop lands there too: inputMode, enterKeyHint, autoFocus, onBlur, onKeyDown (runs first; preventDefault skips the built-in keys), aria-*, data-*. |
| `classNames?` | `Partial<Record<"field" \| "label" \| "description" \| "error" \| "counter" \| "root" \| "box" \| "tag" \| "editor" \| "input" \| "clear" \| "suggestions" \| "list" \| "option", string>>` |  | Extra classes for the parts: the field anatomy, the box, each chip, the in-place editor, the text input, the clear button and the suggestions panel, list and rows. |
| `className?` | `string` |  | Classes for the outermost element: the Field root when there is a label, description or error, else the tag input root. |

## Parts

### tagValidators (constant)

Ready-made validate functions: tagValidators.email, .url and .hashtag. Each returns false for a bad tag, so labels.invalid names it.

### TagInputRejectReason (type)

Why onReject fired: "invalid", "duplicate", "limit" or "notInList".

### TagInputSuggestion (type)

One suggestion row: the Listbox option shape { value, label, description?, icon?, badge?, disabled?, keywords? }.

## Examples

- **Emails**: Email validation, paste a list, clear all and form submit with FormData.getAll.
- **Backspace modes**: edit, select and remove, side by side.
- **Only from the list**: restrictToSuggestions with suggestions open on focus.
- **Limit**: max={5} with the count in the description; the input hides at the limit.
- **Chip styles**: Sizes, box variants, chip variants, tones and a custom color.
- **Hashtags**: transform lowercases, strips spaces and adds #; the hashtag validator.
- **States**: Invalid, read only and disabled.

## See also

- [Multi Select](https://wingo-ui.com/components/multi-select) ([Markdown](https://wingo-ui.com/components/multi-select.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))

---

Canonical: https://wingo-ui.com/components/tag-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
