Tags Input

Pro

A text field that turns what you type into chips inside the field. It wraps and grows as tags arrive, splits pasted lists, and lets a validate hook mark a tag invalid or turn it away.

mara@koalaui.com
daniel@koalaui.com

Separate addresses with a comma, a space or Enter.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Activate your Pro license (once per machine)npx koalaui-cli@latest login <key># Add this component (its dependencies come along)npx koalaui-cli@latest add tags-input

Usage#

This example is part of Koala UI Pro

The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.

Have a license? Unlock it here

Anatomy#

One component, built from two: the Input frame and Chip. The frame keeps every Input token (border, brand focus ring, destructive state, the size ladder) and trades its fixed height for a minimum, so a second row of chips grows it.

import { TagsInput } from "@/components/ui/tags-input"<TagsInput />// renders// <InputRoot>                the field frame, min-height = size//   <div role="grid">        display: contents, chips and input share one wrap//     <Chip asChild>//       <div role="row">     data-slot="tags-input-tag", one gridcell inside//         label//         <ChipRemove />     "Remove {tag}"//   <InputField />           the text you type

Sizes#

The Input ladder: sm 32px, md 36px and lg 40px at rest. Chips step with it (20px in sm, 24px above) and sit an even inset from the frame, so their corners stay concentric. Unsized, the field takes its default from the domain or the container, the same as Input and Button.

react
next
react
next
react
next

Wrapping#

Chips wrap inside the field and the frame grows a row at a time. The text input keeps a minimum width and drops to the next row when the last one is full. A tag longer than the field truncates instead of overflowing.

onboarding
billing
enterprise
api
security review
q4-renewals
a-really-long-tag-that-truncates-at-the-edge-of-the-field

Delimiters & paste#

delimiters decides what ends a tag. Single characters split typed and pasted text; key names commit on that key. The default, comma and Enter, keeps spaces inside a tag. Enter on an empty input still submits the form. A pasted list is split on the character delimiters and on line breaks, so a column copied from a spreadsheet arrives as one tag per row.

react
design system

Comma or Enter. Spaces stay inside a tag.

figma
tailwind

Space, comma, Enter or Tab. Paste a list to add it all at once.

Validation#

validate answers for each tag. true keeps it, "invalid" keeps it as a destructive outline chip (the frame turns destructive with it), and false turns it away: the text stays in the input so it can be repaired, and the frame gives the house rejection shake. Duplicates and tags past max are rejected the same way, and onReject tells you why.

The shake is three damped swings on ease-sine, 2 to 4px by size, and only the frame moves: the label and hint hold still so the message is readable at once. It is skipped under prefers-reduced-motion. The verdict also runs on every render with the tag’s index and the whole list, so a limit like “free seats” can mark the tags past it.

priya@koalaui.com
lena@koalaui, invalid

Try a word without @, or an address that is already there.

Max#

max caps the list. A tag past it is rejected with the shake, and the typed text waits in the input until a chip is removed.

urgent
billing

2 of 3 labels

Controlled#

Pass value and onValueChange for the list, and inputValue with onInputValueChange for the text being typed. Both work uncontrolled with defaultValue.

design
engineering
2 teams

States#

hasError paints the destructive frame and is read from a surrounding Field. readOnly keeps the chips readable and reachable with the arrow keys but drops the remove buttons. disabled dims the whole field.

finance

Pick at least two teams.

finance
legal
finance
legal

Keyboard#

The text input is the field’s only tab stop. From an empty input, Backspace selects the last chip and a second Backspace removes it, so one stray key never deletes a tag. ArrowLeft at the start of the input moves onto the chips; on a chip, ArrowLeft / ArrowRight move between them, Home jumps to the first, End or Escape return to the input, and Backspace / Delete remove it. Typing a letter on a chip goes back to the input.

The chips follow the tag-group pattern: a grid named by the field’s label, one row per chip with its text and remove button in a single gridcell, so a focusable control never nests inside an option. The selected chip carries aria-selected, an invalid one says so to screen readers, and each remove button is named “Remove tag” but kept out of the tab order, since Backspace and Delete do its job from the keyboard. Additions, removals and rejections are announced through a polite live region.

Labels#

The words the field says on its own (the remove buttons, the announcements, the fallback name) come from LabelsProvider: tags, removeTag, invalidTag, tagAdded, tagRemoved and tagRejected. A removeLabel prop wins over the provider.

factura
pendiente

Los botones de quitar dicen «Quitar factura».

API reference#

TagsInput

An InputRoot frame holding a grid of Chip rows and an InputField. Native input props (id, autoFocus, inputMode, aria-*, handlers) and ref go to the text input; className merges onto the frame, last.

PropTypeDefaultDescription
valuestring[]-The tags, controlled.
defaultValuestring[][]The initial tags, uncontrolled.
onValueChange(value: string[]) => void-Called with the new list when tags are added or removed.
inputValuestring-The text being typed, controlled.
onInputValueChange(value: string) => void-Called on every edit of the typed text.
delimitersstring[][",", "Enter"]What ends a tag. One character (",", ";", " ") splits typed and pasted text; a key name ("Enter", "Tab") commits on that key.
addOnPastebooleantrueSplits pasted text on the character delimiters and line breaks, adding every part at once.
addOnBlurbooleanfalseCommits the typed text when focus leaves the field.
allowDuplicatesbooleanfalseLets the same tag in twice. Off, a duplicate is rejected.
maxnumber-The most tags the field holds. Past it, a new tag is rejected.
normalize(raw: string) => stringtrimRuns on every part before it is checked. An empty result is ignored.
validate(tag: string, context: { tags: string[]; index: number }) => boolean | "invalid" | undefined-false rejects the tag (it stays in the input and the field shakes); "invalid" keeps it as a destructive chip. Also runs on every render, so a verdict can depend on the rest of the list.
onReject(tag: string, reason: "invalid" | "duplicate" | "max") => void-Called for each rejected tag. Use it to say why.
size"sm" | "md" | "lg"from contextThe Input ladder: 32, 36 or 40px at rest. Defaults through useControlSize, like Input.
density"comfortable" | "compact"-Picks the default size when size is unset.
hasErrorboolean-Destructive frame. Unset, it turns on while any chip is invalid, then falls back to a surrounding Field.
disabledbooleanfalseDims the frame, disables the input and hides the remove buttons.
readOnlybooleanfalseChips stay readable and focusable; typing and removing are off.
placeholderstring-Shown while there are no tags.
namestring-Renders one hidden input per tag so the list posts with a native form.
removeLabel(tag: string) => stringlabels.removeTagThe remove button's accessible name. Overrides LabelsProvider.
inputClassNamestring-Merged onto the text input.
Data attributeDescription
data-slot="tags-input-tag"On each chip, a div with role="row".
data-invalidOn a chip whose verdict is "invalid".
data-selectedOn the chip that has keyboard focus.
data-errorOn the frame while it is destructive.
data-readonlyOn the frame while readOnly.
CSS variableDescription
--shake-distanceHow far the frame travels when a tag is rejected: 2, 3 or 4px by size.

FAQ#

When the values are open-ended and typed: email addresses, keywords, labels the user invents. MultiSelect picks from a known list in a popover.