Composites
Tags Input
A text field that turns what people type into removable tags — free-form labels on a task or a CRM record, the recipients of an email. There is no list to pick from: any text that passes your validator becomes a tag, which is exactly what separates it from a MultiCombobox.
- Import
TagsInput,TagsInputLabel,TagsInputList,TagsInputItem,TagsInputInputandTagsInputClearfrom the main entry,@comitor/ui.- Install
- Nothing extra — no optional peer. The headless core,
@diceui/tags-input, is an ordinary dependency of the package. - Runtime
- A client component (
"use client"). Keep the field inside a client component of your own:onValidate,onInvalidanddeleteLabelare functions, and functions cannot cross from a Server Component. - Origin
- Added in
2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package's own primitives and tokens. What changed on the way is below and in the changelog.
Anatomy
You render the tags yourself, from the value. The root owns that value and everything that changes it; the list is presentation — a div dressed as an Input — and each item finds its position by looking its value up in the root's array. Upstream's separate text and delete parts are folded into TagsInputItem, so there is nothing to compose inside a tag.
import {
TagsInput,
TagsInputClear,
TagsInputInput,
TagsInputItem,
TagsInputLabel,
TagsInputList,
} from '@comitor/ui'
<TagsInput value={tags} onValueChange={setTags}>
{/* A <label> wired to the text field */}
<TagsInputLabel>Labels</TagsInputLabel>
{/* The box people see — Input's border and focus ring. Tags and the field share it. */}
<TagsInputList>
{tags.map((tag) => (
// One per tag: the text, then a ✕ button named "Remove <tag>"
<TagsInputItem key={tag} value={tag}>
{tag}
</TagsInputItem>
))}
<TagsInputInput placeholder="Add a label…" />
</TagsInputList>
{/* Optional. Rendered only while there are tags, and it ships with no content. */}
<TagsInputClear>Clear all</TagsInputClear>
</TagsInput>Labels on a task
The everyday case: free text, committed with Enter or a comma, capped with max. Add a label, then press Backspace in the empty field — once highlights the last tag, twice removes it — and walk the tags with ← and →. With editable, double-click a tag (or highlight it and press Enter) to rename it in place.
2 of 5 · Enter or a comma adds a label
value: ["design","q3-launch"]
- Refusals are silent unless you speak. At the limit, on a duplicate, or when
onValidatesays no, the entry is refused andonInvalidreceives it. The component itself shows nothing: a failed validator or a duplicate setsdata-invalidon the root and the field, which nothing styles, and the limit sets nothing at all — the sentence under this field is the demo's own. - Duplicates are exact matches after trimming: Design and design are two tags. A duplicate typed with Enter is cleared from the field as if it had been added; every other refusal typed with Enter leaves the text where it was, ready to correct.
maxis a count, not a disabled state. At five labels the field still takes text — the sixth is refused on Enter — so the demo drops its placeholder and says so.- The validator here refuses only empty strings. Without it, a space followed by Enter adds an empty tag — see Known limitations.
Email recipients
Addresses are validated before they become tags, the ones that fail are reported, and a list pasted from a mail client is taken apart. Type minh@comitor and press Enter — it stays in the field, with the reason below. Then copy the list above the field and paste it in.
Anna Nguyen <[email protected]>; [email protected], sales@, Linh Pham <[email protected]>Enter or a comma after each address. Paste a list straight from your mail app.
value: ["[email protected]"]
onValidatesees the trimmed text for every way in — Enter, the delimiter, Tab withaddOnTab, leaving the field withblurBehavior="add", pasted pieces and in-place edits.- This demo owns paste.
addOnPastereports nothing for pasted pieces that fail validation — they vanish — and splits on one delimiter only. ItsonPasteonTagsInputInputruns before the component's and callspreventDefault(), then splits on commas, semicolons and line breaks, pulls the address out ofName <address>, and lists what it could not use. A single address with no separator is left to paste as ordinary text. blurBehavior="add"commits a valid address when focus leaves the field. Without it, an address someone typed and then tabbed away from stays in the field and is never sent.- Case: the validator refuses an address already on the list in any case, and
onValueChangelowercases what it stores. The component's own duplicate check is case-sensitive, so a mail field has to do this itself. aria-invalid, the red border and therole="alert"message are all the app's — see Accessibility.
Pasting a comma-separated list
When lists arrive comma-separated — tags copied from a spreadsheet or another record — addOnPaste splits the pasted text on delimiter. Copy the line above the field, click into it and paste. Then paste the same line a second time.
apac, renewal-q4, enterprise, key-account, apac2 of 8 · value: ["enterprise","logistics"]
- First paste: pieces are trimmed, empty ones dropped and repeats collapsed — apac lands once. enterprise is already on the account, so it goes to
onInvalidinstead. - Second paste: nothing is added. The limit is checked before anything else and counts every piece, repeats included — five tags plus five pieces is over eight — so the whole paste is refused and
onInvalidreceives the entire pasted string, not a tag. delimiteris also what typing listens for: the field commits when the last character typed equals it. Keep it to one character — a longer one still splits a paste but never fires while typing.- Without
addOnPastea paste is plain text: apac, renewal-q4 becomes a single tag, comma and all, when Enter is pressed.
What the port guarantees
The keyboard, paste, in-place editing and the hidden form input are the headless core's; the file in the package is a styled wrapper. These are the places where it deliberately does not match upstream.
- Every ✕ has a name.
deleteLabel(value)reads Remove q3-launch by default. Upstream pointed the button'saria-labelledbyat the tag's own text, so a screen reader heard q3-launch, button — a button that sounds like the tag, not like removing it. Becausearia-labelledbybeatsaria-labelin the name computation, the port removes it rather than adding a label beside it. - A disabled ✕ never takes focus. Upstream set
tabIndex={-1}only on enabled buttons — backwards. The working buttons left the Tab order (on purpose: the keyboard removes tags from the text field) while disabled ones joined it, focusable and inert. The port pins-1in both states; a screen reader's browse mode still reaches them. - Right-to-left follows your DirectionProvider.
@diceui/sharedkeeps a private direction context the package cannot export, so Radix's<DirectionProvider dir="rtl">— where every other component here gets its direction — never reached the field. The port reads Radix's direction and passes it down asdir(your owndirstill wins), and ← and → swap as they should. - No prohibited ARIA on a tag. Upstream named each tag — a role-less
div— witharia-labelledby, which ARIA 1.2 forbids on the generic role (axe:aria-prohibited-attr). The text is inside the tag, so nothing is lost. - It lines up with Input. The list wears Input's border recipe —
border-inputat rest,--control-edge-strongon hover,--primary-inkand aring-2 ring-ring/50while focus is anywhere inside — and hovering a focused field no longer repaints the focus colour. Empty, it is Input's 36px (min-h-9 py-1.5, down from upstream'smin-h-10 py-2), so it sits in a form row without a step. - It fills its container. Upstream pinned the root at
w-[380px], the width of its demo page. Here the root isw-fulllike Input, and the text field getsmin-w-0so an input's intrinsic width no longer pushes tags onto a new line early. - Disabled looks disabled.
disabled:never matches adiv, and upstream'speer-disabled:never matched the label, which comes before the input. The box now dims throughhas-[input:disabled]— the real:disabledstate, the one WCAG 1.4.3 and 1.4.11 exempt — and the label throughin-data-disabled.
Language
Tags Input ships almost no text. The label, the placeholder and whatever you put in the clear button are yours, so they follow your own copy. The one string the package owns is the name of each ✕ button — invisible on screen, which is why the preview reads it back out of the DOM. Switch the language and watch it change.
Demo language
Accessible names of the ✕ buttons, read from the DOM:
—
- Namespace
tagsInput, one key:deleteLabel— Remove {value} in English, Xoá thẻ {value} in the built-in Vietnamese table (comitorMessagesVi.tagsInput). It is a function because the tag sits inside the sentence, and the words around it move from language to language. - Precedence: the
deleteLabelprop onTagsInputItem→ the nearestComitorLocaleProvider→ English. There is nolabelsobject and noDEFAULT_*_LABELSconstant for this component. - The provider changes strings, not the
langattribute that tells a screen reader how to pronounce them. Set it on<html>in the app; the preview sets it on its own wrapper. - Where the provider goes, and how
localeis chosen: Get started → Set the language.
'use client' // deleteLabel is a function — it cannot come from a Server Component
import { ComitorLocaleProvider, TagsInputItem, type ComitorMessagesOverride } from '@comitor/ui'
// One field: the prop on the item beats the provider.
<TagsInputItem value={address} deleteLabel={(value) => `Remove recipient ${value}`}>
{address}
</TagsInputItem>
// The whole app: override the namespace once. Module-level, so the reference is stable.
const MESSAGES: ComitorMessagesOverride = {
tagsInput: { deleteLabel: (value) => `Remove the “${value}” tag` },
}
<ComitorLocaleProvider locale="en" messages={MESSAGES}>{children}</ComitorLocaleProvider>Props
TagsInput — the headless core's props, plus the port's dir bridge. Every other div attribute passes through.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string[] | — | The tags, controlled. You render one TagsInputItem per entry; the root owns the keyboard, paste, validation and the hidden form input. |
defaultValue | string[] | [] | Starting tags when uncontrolled. Render the items with the function form of children, which receives the current value. |
onValueChange | (value: string[]) => void | — | Called with the next array after an add, a removal, an edit, Clear or a paste — user actions only. Changing value yourself does not call it back. |
max | number | Infinity | Most tags allowed. At the limit a typed entry is refused and handed to onInvalid; a paste that would cross it is refused whole. The field is not disabled — it keeps taking text. |
delimiter | string | ',' | Typing it as the last character commits the text before it, and addOnPaste splits on it. Keep it one character: typing is detected by comparing the last character typed, so a longer delimiter only ever works for paste. |
addOnPaste | boolean | false | Split pasted text on delimiter into tags. Pieces are trimmed, empty ones and repeats dropped, and pieces that fail onValidate are dropped without calling onInvalid. Off, a paste is plain text in the field. |
addOnTab | boolean | false | Tab commits the typed text and keeps focus in the field. In an empty field Tab moves on as usual. |
blurBehavior | 'add' | 'clear' | — | What happens to typed text when focus leaves. 'add' commits it (validation applies, refused text stays put); 'clear' discards it. Unset, the text stays in the field, uncommitted. |
editable | boolean | false | Tags can be renamed in place: double-click one, or highlight it from the keyboard and press Enter. Enter saves — the validator and the duplicate check apply — and Escape or leaving the field cancels. |
onValidate | (value: string) => boolean | — | Called with the trimmed text before it becomes a tag — typed, tabbed, blurred, pasted or edited. Return false to refuse it. |
onInvalid | (value: string) => void | — | Called when an entry is refused: a failed onValidate (typed or edited), a duplicate, or the max. At the max it receives the raw text — for a paste, the entire pasted string. Not called for pasted pieces that fail onValidate. |
displayValue | (value: string) => string | (value) => value | How a tag reads when its TagsInputItem has no children. It is also applied to the text of an edited tag before that text is stored. |
loop | boolean | false | ← and → wrap from the first tag to the last and back. |
disabled | boolean | false | Puts a real disabled attribute on the text field and stops every ✕. The box dims through has-[input:disabled], the label through in-data-disabled. |
readOnly | boolean | false | Refuses typing, paste, edits and removal, and the arrow keys stop walking tags. The ✕ buttons still render and look active — see Known limitations. |
required | boolean | false | Set on the hidden form input only. Browsers never validate hidden inputs, so nothing blocks an empty submit — check value.length yourself. |
name | string | — | With a name, inside a <form>, a hidden input posts the tags as a JSON array: labels=["design","q3-launch"]. |
dir | 'ltr' | 'rtl' | DirectionProvider, else 'ltr' | Reading direction; under 'rtl' the ← and → keys swap. Unset, the port reads Radix's DirectionProvider — see What the port guarantees. |
children | ReactNode | ({ value }) => ReactNode | — | The label, the list and the clear button. The function form hands you the current tags. |
className | string | — | Merged onto the root: a w-full flex column with gap-2. Width is your layout's job. |
TagsInputItem:
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | The tag this item stands for. The item finds its position with indexOf on the root's array, so it must be one of the tags — which, with duplicates refused, also makes it a safe React key. |
children | ReactNode | displayValue(value) | The visible text. It truncates with an ellipsis when a tag is wider than the box. |
disabled | boolean | false | Dims the tag and makes its ✕ ignore clicks and stay out of the Tab order. It does not reliably stop keyboard removal — see Known limitations. |
deleteLabel | (value: string) => string | tagsInput.deleteLabel | Accessible name of the ✕ button: Remove design by default, Xoá thẻ design under a Vietnamese provider. A function because the tag sits inside the sentence. The prop wins over the provider. |
TagsInputInput — every input attribute, and:
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | — | No default — TagsInput ships no visible text of its own. |
aria-label | string | — | Names the field when there is no TagsInputLabel. The field is always aria-labelledby the label's id, so without a label that reference dangles — a visually hidden TagsInputLabel (className='sr-only') is the cleaner fix. |
autoFocus | boolean | false | Focuses the field on the animation frame after it mounts. |
onKeyDown / onChange / onPaste / onBlur | event handlers | — | Yours run before the component's. Calling event.preventDefault() makes the component skip its own handling of that event — the recipients demo takes paste over this way. |
TagsInputClear — a <button type="button">:
| Prop | Type | Default | Description |
|---|---|---|---|
forceMount | boolean | false | Unset, the button exists only while there are tags. forceMount keeps it in the DOM with data-state='invisible' when empty — for an exit animation, or a layout that must not shift. |
asChild | boolean | false | Render your own element — a package Button — instead of a bare button. |
children / aria-label | ReactNode / string | — | No default content. Give it text, or an icon plus aria-label. Clicking empties the list and returns focus to the text field. |
TagsInputLabel is a <label> already wired to the text field, and TagsInputList a div; both take className and nothing of their own.
Known limitations
All of these are in the headless core; the workaround is what an app can do today.
- Moving between tags is not announced. ← and → highlight a tag with
data-highlightedandaria-current, but focus stays in the text field and the core does not usearia-activedescendant, so a screen reader says nothing. Removal still works for everyone — every ✕ is a named button in browse mode — but if a screen depends on keyboard tag editing, offer the same actions somewhere that is announced. - A disabled tag is not safe from the keyboard. The core walks tag positions counting only enabled tags, so when a disabled tag is not the last one, Backspace can highlight it and remove it. Its ✕ ignores clicks but is not a disabled button either. Keep locked tags — system labels, say — outside the field, as Badges.
- Blank tags. A space and then Enter (or a comma) adds an empty tag, and editing a tag down to nothing stores an empty one. Refuse them:
onValidate={(v) => v.length > 0}— the value arrives trimmed. Every demo on this page does. - The delimiter forgets. Typing the delimiter clears the field even when the entry is refused; Enter leaves refused text in place. If people can commit with a comma, show the reason from
onInvalid— it receives the text the field just dropped. - Paste reports little. With
addOnPaste, pieces that failonValidateare dropped withoutonInvalid, and a paste that would crossmaxis refused whole. When rejects must be listed, take paste over, as the recipients demo does. - required is not enforced. It lands on the hidden input, and browsers never validate hidden inputs. Check
value.lengthbefore you submit. - readOnly leaves the ✕ looking live. The buttons look exactly as they do in an editable field, and pressing them does nothing. For a read-only view, render the tags as Badges.
Related
- Combobox and MultiCombobox — when the values come from a fixed list (projects, people, statuses) rather than free entry. Their search also ignores Vietnamese accents.
- Mention — people named inside running text, rather than as a separate list of tags.
- Badge Overflow — showing the saved tags in a table cell or card, collapsing the rest into “+N”.
- Patterns where it belongs: Tasks (labels on a task), Email (recipients in the composer) and CRM (tags on a contact record).
Accessibility
Keyboard work happens in the text field. The table covers the default dir="ltr".
| Key | When | What happens |
|---|---|---|
| Enter | Text in the field | Commits it as a tag. Never submits the surrounding form. |
| Enter | A tag highlighted, editable on | Edits that tag in place. Enter saves, Escape cancels, leaving the field cancels. |
| , | Typed as the last character | Commits the text before it (the delimiter). |
| Tab | Text in the field, addOnTab on | Commits it and keeps focus in the field. |
| Backspace | Caret at the start of the field | First press highlights the last tag; the next press removes it and highlights the one before. |
| Delete | A tag highlighted | Removes it. |
| ←→ | Caret at the start, or a tag highlighted | Walk the tags; → past the last one returns to the text. Swapped under dir='rtl'. |
| HomeEnd | A tag highlighted | First or last tag. |
| Esc | Anytime | Clears the highlight and cancels an edit. |
- The text field is a plain
<input type="text">, labelled byTagsInputLabelthrougharia-labelledbyandhtmlFor. The field carries thataria-labelledbywhether or not the label is rendered, so where a design has no visible label, keep the label and give itclassName="sr-only"rather than reaching foraria-label. - Tags are not a listbox or a grid: they are text in the box, each followed by a
<button type="button">named bydeleteLabel. Those buttons are out of the Tab order on purpose — Backspace and Delete do the job from the field — and a screen reader's browse mode reaches them. - Focus is never dropped: removing a tag by ✕ or by keyboard, Clear, and saving an edit all put focus back in the text field, and clicking empty space in the box focuses it too.
- At most, the component marks a refusal with
data-invalid— and not even that at the limit. The app setsaria-invalid, pointsaria-describedbyat the reason and puts that reason in a live region — the recipients demo does all three, with a-inkred border that stays red on hover and focus. - In a
<form>: withname, a hidden input posts the tags as JSON. Enter with text in the field commits a tag and never submits; Enter in an empty field submits like any text input.requireddoes nothing — validate the count yourself. - The highlighted tag is
bg-accentwithtext-accent-foreground, the same pair as a highlighted command-menu row. A tag's border is decorative (--border): the control is the ✕, not the tag. - Still yours: a label (visible, or sr-only), a hint that says how to add (“Enter or a comma”), the reason for every refusal, a name on an icon-only
TagsInputClear, and thelangattribute on<html>. - The field — and so the text typed into it — is 16px below
mdand 14px frommdup, like every field since 3.13.4; the tags themselves stay 14px. Leave the size to the package; Input explains why.