# 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`
- **Page:** <https://ui.booleanpress.com/components/tags-input> · @booleanpress/ui 0.2.0

## Usage

`TagsInput` is a field of the library's `Chip`s 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](https://base-ui.com/react/components/autocomplete).

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| 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. |
| Shift + Tab | 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`.

| 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 |
