ComponentsForm
Tags input
A text field that turns what people type into removable tags, such as labels, addresses or domains.
Import
import { TagsInput } from "@booleanpress/ui/tags-input"Also install @base-ui/react: pnpm add @base-ui/react
Usage
TagsInput is a field of the library's Chips followed by a text input: Enter adds what was typed as a tag, and each tag's remove button takes it out. It needs the @base-ui/react peer package: with suggestions, the input is Base UI's Autocomplete.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export function LabelsField() {
return (
<>
<Label htmlFor="labels">Ticket labels</Label>
<TagsInput id="labels" defaultValue={["billing"]} delimiter="," placeholder="Add a label" />
</>
)
}It is uncontrolled with defaultValue, or controlled with value and onValueChange, which carry the tags as an array of strings. Other props (id, placeholder, aria-invalid, aria-describedby) go to the input; with name, each tag is submitted as a hidden input of that name (none while disabled), and required asks for at least one tag. A form reset brings back defaultValue.
Tags are trimmed and empty text is never added. delimiter (such as ",") also ends a tag as it is typed, and splits pasted text into several. A tag already in the field is not added again unless allowDuplicates; max caps the count, after which the input stops taking text. tagIcon puts an icon before each label, or one chosen per tag. suggestions lists values to add as people type, leaving out those already added; picking one adds it, and Enter still adds free text. The list opens only while a suggestion matches, and a full field offers none. size and variant="filled" default to the provider's controlSize and fieldVariant. For tags chosen from a fixed list, use Combobox with multiple or MultiSelect.
Examples
Basic
Enter adds the typed text as a tag; Backspace in the empty input removes the last one.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputBasic() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-labels">Ticket labels</Label>
<TagsInput id="tags-input-labels" defaultValue={["billing"]} placeholder="Type a label, then Enter" />
</div>
)
}Delimiter
delimiter=",": a comma ends a tag as well as Enter, and a pasted list is split into tags.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputDelimiter() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-cc">Copy delivery reports to</Label>
<TagsInput id="tags-input-cc" delimiter="," placeholder="ops@example.com, finance@example.com" />
<p className="text-xs text-muted-foreground">Separate addresses with a comma or Enter; pasted lists are split too.</p>
</div>
)
}No duplicates
A tag already in the field is not added again; allowDuplicates lets repeats through.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputNoDuplicates() {
return (
<div className="flex w-full max-w-xs flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="tags-input-unique">Blocked domains</Label>
<TagsInput id="tags-input-unique" defaultValue={["spam.example", "junk.example"]} placeholder="Add a domain" />
<p className="text-xs text-muted-foreground">Typing spam.example again adds nothing.</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tags-input-repeat">Retry delays (minutes)</Label>
<TagsInput id="tags-input-repeat" defaultValue={["5", "5", "15"]} allowDuplicates placeholder="Add a delay" />
</div>
</div>
)
}Max
max={5}: once five tags are in, the input stops taking text.
import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputMax() {
const [keywords, setKeywords] = useState(["invoice", "receipt"])
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-max">Priority keywords</Label>
<TagsInput id="tags-input-max" value={keywords} onValueChange={setKeywords} max={5} placeholder="Add a keyword" />
<p className="text-xs text-muted-foreground">{keywords.length} of 5 keywords.</p>
</div>
)
}Custom tag
tagIcon as a function gives each tag its own icon: an at sign for an address, a globe for a domain.
import { AtSignIcon, GlobeIcon } from "lucide-react"
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputCustomTag() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="tags-input-allow">Allowed senders</Label>
<TagsInput
id="tags-input-allow"
defaultValue={["billing@example.com", "example.org"]}
tagIcon={(tag) => (tag.includes("@") ? <AtSignIcon /> : <GlobeIcon />)}
placeholder="An address or a domain"
/>
</div>
)
}Typeahead
suggestions lists values as you type; picking one adds it, and Enter still adds your own text.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
const SUGGESTIONS = ["billing", "bounce", "deliverability", "dkim", "dns", "feature request", "onboarding", "refund", "spf"]
export default function TagsInputTypeahead() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-typeahead">Ticket labels</Label>
<TagsInput id="tags-input-typeahead" suggestions={SUGGESTIONS} defaultValue={["dns"]} placeholder="Type to see suggestions" />
</div>
)
}Sizes
sm is 28 px high, default 35 px and lg 42 px, with the tags sized to match.
import { TagsInput } from "@booleanpress/ui/tags-input"
const SIZES = [
{ size: "sm", label: "Small" },
{ size: "default", label: "Default" },
{ size: "lg", label: "Large" },
] as const
export default function TagsInputSizes() {
return (
<div className="flex w-full max-w-xs flex-col gap-4">
{SIZES.map(({ size, label }) => (
<TagsInput key={size} size={size} defaultValue={["billing"]} placeholder={label} aria-label={`Labels, ${label.toLowerCase()} size`} />
))}
</div>
)
}Filled
variant="filled" fills the field with the grey --field-filled, on hover and focus too.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputFilled() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-filled">Ticket labels</Label>
<TagsInput id="tags-input-filled" variant="filled" defaultValue={["refund"]} placeholder="Add a label" />
</div>
)
}Disabled
disabled keeps the tags, fills the field grey, disables the input and leaves out the remove buttons.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputDisabled() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-disabled">Ticket labels</Label>
<TagsInput id="tags-input-disabled" disabled defaultValue={["billing", "refund"]} />
</div>
)
}Invalid
aria-invalid draws the error edge and placeholder; aria-describedby reads the message with the name.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputInvalid() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-invalid">Alert recipients</Label>
<TagsInput id="tags-input-invalid" placeholder="Add an address" aria-invalid aria-describedby="tags-input-invalid-error" />
<p id="tags-input-invalid-error" className="text-xs text-destructive-strong">
Add at least one address to send alerts to.
</p>
</div>
)
}Read-only
readOnly shows the tags without remove buttons and takes no typing; the tags are still submitted.
import { Label } from "@booleanpress/ui/label"
import { TagsInput } from "@booleanpress/ui/tags-input"
export default function TagsInputReadOnly() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="tags-input-read-only">Ticket labels</Label>
<TagsInput id="tags-input-read-only" readOnly defaultValue={["billing", "refund", "priority"]} />
</div>
)
}Accessibility
- Semantics
- The tags are a
role="list"ofrole="listitem"chips, before a native text input (arole="combobox"witharia-autocomplete="list"when it has suggestions). Each chip has a removebuttonnamed "Remove {label}". - Labels
- Name the input with a
LabelwhosehtmlForis itsid,aria-labeloraria-labelledby. Withsuggestions, while the list is open Base UI hides the rest of the page from assistive technology, the visible label included; so the input also carries its labels' text as its ownaria-label, read when it mounts, as the list opens and when the label's text changes, and keeps its name. The list is named "Suggestions" and the remove buttons "Remove {label}", from the provider'ssuggestionsandremoveItemstrings. - Focus
- Each remove button is a tab stop before the input. Removing a tag moves focus to the next tag's button, else the previous one's, else back to the input. A press on the field's empty space puts the cursor in the input.
- Known limits
- Adding a tag is not announced; the new chip appears in the list before the input.
- A refused tag (a duplicate, or over
max) stays in the input as typed.
Keyboard
| Key | Behaviour |
|---|---|
| Enter | Adds the typed text as a tag; with a suggestion highlighted, adds that suggestion. |
| , | With delimiter=",", adds the typed text as a tag. |
| Backspace | In the empty input, removes the last tag. On a remove button, removes its tag. |
| Delete | On a remove button, removes its tag. |
| ShiftTab | From the input, moves to the last tag's remove button. |
| โorโ | With suggestions, highlight the next or the previous suggestion. |
| Escape | With suggestions, closes the list; inside a dialog, the dialog stays open. |
API
TagsInput
Renders a input and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
allowDuplicates | boolean | false | Lets the same tag be added more than once. Off by default: a tag already in the field is not added again. |
defaultValue | string[] | The tags it starts with, when it controls itself. | |
delimiter | string | string[] | Characters that end a tag as they are typed or pasted, besides Enter, such as ",". | |
onValueChange | ((value: string[]) => void) | Called with the new tags whenever one is added or removed. | |
size | "default" | "sm" | "lg" | 28, 35 or 42 px tall while it holds one row. Defaults to the provider's controlSize. | |
suggestions | string[] | Values offered in a list as people type; picking one adds it. Values already added are left out. | |
tagIcon | ReactNode | ((tag: string) => ReactNode) | An icon before each tag's label, or a function that returns one for a tag. | |
value | string[] | The tags, when you control them. Pair it with onValueChange. | |
variant | "default" | "filled" | filled fills the field grey. Defaults to the provider's fieldVariant. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="tags-input" (TagsInput), and data-size, data-variant, data-disabled.
Provider strings: suggestions (BooleanUIProvider's strings).
Theming
The field takes Input's tokens: --field (--field-filled filled), --control for the edge (--control-hover under the pointer), --ring while the input has focus, --invalid when invalid, --field-disabled when disabled. Tags are the library's Chip on --secondary. Suggestions use --popover and --accent, with the motion set once in theme.css.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--accent-foreground | text |
--border | border |
--control | border |
--control-hover | border |
--destructive-strong | text |
--field | background |
--field-disabled | background |
--field-disabled-foreground | text |
--field-filled | background |
--foreground | text |
--invalid | border |
--muted-foreground | text |
--popover | background |
--popover-foreground | text |
--ring | border |