Skip to the content

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" of role="listitem" chips, before a native text input (a role="combobox" with aria-autocomplete="list" when it has suggestions). Each chip has a remove button named "Remove {label}".
Labels
Name the input with a Label whose htmlFor is its id, aria-label or aria-labelledby. With suggestions, 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 own aria-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's suggestions and removeItem strings.
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

Keyboard
KeyBehaviour
EnterAdds the typed text as a tag; with a suggestion highlighted, adds that suggestion.
,With delimiter=",", adds the typed text as a tag.
BackspaceIn the empty input, removes the last tag. On a remove button, removes its tag.
DeleteOn a remove button, removes its tag.
ShiftTabFrom the input, moves to the last tag's remove button.
โ†“orโ†‘With suggestions, highlight the next or the previous suggestion.
EscapeWith suggestions, closes the list; inside a dialog, the dialog stays open.

API

TagsInput

Renders a input and passes it every other prop.

TagsInput props
PropTypeDefaultDescription
allowDuplicatesbooleanfalseLets the same tag be added more than once. Off by default: a tag already in the field is not added again.
defaultValuestring[]The tags it starts with, when it controls itself.
delimiterstring | 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.
suggestionsstring[]Values offered in a list as people type; picking one adds it. Values already added are left out.
tagIconReactNode | ((tag: string) => ReactNode)An icon before each tag's label, or a function that returns one for a tag.
valuestring[]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.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--borderborder
--controlborder
--control-hoverborder
--destructive-strongtext
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--invalidborder
--muted-foregroundtext
--popoverbackground
--popover-foregroundtext
--ringborder