TagsInput
A text field that turns what you type into tags: Enter or a comma adds one, Backspace takes the last one away.
cennge-ui · React UI library
- Type a topic and press Enter or a comma. Paste “nextjs, radix-ui” and both arrive at once.
- Type “React” and press Enter: it is refused as a copy of react, which flashes. The text stays to be fixed, with the reason under the field.
- In the empty field, Backspace removes the last tag; ← walks the tags and Delete removes the one in focus.
Field marks
- Import
import { TagsInput } from "cennge-ui";- Size
- 5.2 KB gzip for this import, its dependencies aside
- Pattern
- Native text
<input>; the tags' remove buttons stay out of the tab order - Keyboard
- Enter ,Backspace Arrow left Arrow right Delete Esc
- State
- Controlled with
valueandonValueChange(astring[]), or uncontrolled withdefaultValue - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Refusals
- Duplicates in any letter case,
validateandmaxTags; the reason shows under the field - Forms
- One hidden input per tag under
name - Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: tags-input
When to use#
Use a TagsInput when the values are open-ended and short: topics, labels, reviewers, file patterns. People type them, paste a list, or let a phone keyboard insert them, and each one becomes a tag they can see and remove. Leftover text becomes a tag when the field loses focus, unless addOnBlur is false.
Similar#
- Combobox with
selectionMode="multiple"also shows its values as tags, but only takes them from its list. Use it when the choices are known. - Input holds one free value as a string.
Rules and limits#
validate refuses a tag by returning false, maxTags stops new ones once the field is full, and a tag that is already there in any letter case is refused unless allowDuplicates is set. A refused tag keeps its text in the field and shows the reason in place of the description until the text changes. formatAnnouncement words each reason. Try “Bug”, then “help wanted”, then add labels until a fourth is refused.
import { TagsInput, type TagsInputEvent } from "cennge-ui";
const messages: Record<TagsInputEvent["type"], (tag: string) => string> = {
added: (tag) => `Added ${tag}`,
removed: (tag) => `Removed ${tag}`,
duplicate: (tag) => `${tag} is already a label`,
invalid: (tag) => `${tag}: use lowercase letters and hyphens`,
limit: () => "Three labels at most",
};
export default function IssueLabels() {
return (
<TagsInput
label="Labels"
defaultValue={["bug"]}
maxTags={3}
validate={(label) => /^[a-z-]+$/.test(label)}
formatAnnouncement={(event) => messages[event.type](event.tag)}
description="Up to three, lowercase"
className="w-full max-w-96"
/>
);
}Your own separators#
separators lists the keys that add a tag; single characters also split text that is pasted or inserted without key events, as Android keyboards and autofill do. Line breaks in pasted text always split. Here a space adds a path too, so site/** package.json pasted at once becomes two tags.
import { TagsInput } from "cennge-ui";
export default function WatchPaths() {
return (
<TagsInput
label="Run CI on changes to"
defaultValue={["src/**", "e2e/**"]}
separators={["Enter", ",", " "]}
placeholder="site/** package.json"
description="Space, comma or Enter adds a path"
className="w-full max-w-96"
/>
);
}In a form#
With a name, every tag is submitted as its own value, so FormData.getAll() returns the list. Enter with text in the field adds a tag; Enter in an empty field submits the form. isRequired counts the tags, not the text: with one tag in place the field is filled.
import { useState } from "react";
import { Button, Form, TagsInput } from "cennge-ui";
export default function ReleaseForm() {
const [sent, setSent] = useState<string[] | null>(null);
return (
<Form
onSubmit={(event) => {
event.preventDefault();
setSent(new FormData(event.currentTarget).getAll("reviewers").map(String));
}}
className="flex w-full max-w-96 flex-col gap-4"
>
<TagsInput name="reviewers" label="Reviewers" placeholder="GitHub username" isRequired description="Enter adds a name; Enter in an empty field submits" />
<Button type="submit" color="primary" className="self-start">
Request review
</Button>
<p aria-live="polite" className="text-sm text-muted">
{sent ? `Submitted reviewers=${JSON.stringify(sent)}` : "Nothing submitted yet"}
</p>
</Form>
);
}Accessibility#
The tags add no tab stops: Tab leaves the field rather than stepping through the remove buttons. With isClearable, the clear button is the next stop. From the empty field, ← moves onto the last tag's remove button, the arrow keys walk the tags, Backspace or Delete removes the one in focus and focus moves to a neighbour, and → past the last tag or Escape returns to the field. A mouse click on a remove button puts focus back in the field.
Screen readers hear each change through a polite live region: "Added react", "Removed react", and every refusal, which is also linked to the field with aria-describedby. Remove buttons are named "Remove react"; translate them with removeLabel and the messages with formatAnnouncement.
Styling#
classNames reaches the inner parts: base, label, inputWrapper, input, tag, clearButton, description and errorMessage.
Props#
Also takes every attribute of <input>, except size, color, value, defaultValue, onChange, which mean something else here.