Source: https://monarchui.dev/components/tag-input/

Components · Forms

# TagInput

Chips for a list of short values: Enter or comma to add, paste to split, keys to remove.

@rookcom/monarch-react

Component

## Skills with suggestions

## Usage examples

### Validated emails

### Read-only and small

## Purpose

Edits a list of short free-form values (skills, labels, keywords, email recipients) as removable chips in one field, with validation, paste splitting and optional suggestions.

### Use when

- The user types several values into one field: labels, skills, Cc addresses.
- Values are mostly free text, perhaps with suggestions of known ones.

### Avoid when

- Values must come from a fixed list: use a multi-select.
- Each value has several properties to edit: use a list or table with an add row.
- Only one value is allowed: use Input or Combobox.

## Design principles

- The product owns the rules: `normalize` and `validate` decide what a valid tag is; the component handles entry, display and removal.
- A rejected value stays in the field with a message, so the user can fix it instead of retyping.
- Removing is never one stray key away: the first Backspace in an empty field moves to the last chip, the second removes it.
- Chips are quiet, neutral surfaces; colour is left for status, not decoration.

## API reference

```tsx
import { TagInput } from '@rookcom/monarch-react';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `TagInput` | component | client | `.mn-tag-input` |

Renders a `.mn-tag-input` wrapper containing the chip control (`.mn-tag-input__control`, styled as `.mn-input`), the message and a hidden status region. The ref and native input props go to the text input; `className` goes to the wrapper.

With `suggestions`, the text input becomes a `combobox` and the list is a Radix Popover, as in Combobox.

With `name`, one `<input type="hidden">` per tag is rendered for native form posts.

Generated from the TypeScript declarations of `packages/react/src/components/forms/tag-input`.

## Properties

`validate(tag, tags)` returns a message to reject the tag, or `null`/`undefined` to accept it. It runs after `normalize`, the duplicate check and `max`.

### `TagInput`

Edits a list of short values as removable chips: skills, labels, recipient addresses. Enter or a separator adds the typed text, pasted lists are split, a validator can reject values, and optional suggestions appear in a listbox.

| Prop | Type | Description |
| --- | --- | --- |
| `addOnBlur` | `boolean` | Commit the typed text when the field loses focus. Default false. |
| `allowDuplicates` | `boolean` | Allow the same tag twice. Duplicates are compared case-insensitively. Default false. |
| `children` | `ReactNode` |  |
| `className` | `string` | Class for the outer wrapper (the text input is `.mn-tag-input__input`). |
| `defaultValue` | `string[]` | Initial tags when uncontrolled. |
| `disabled` | `boolean` |  |
| `filterSuggestions` | `((suggestion: string, query: string) => boolean)` | Which suggestions match the typed text. Default: case-insensitive substring. Pass `() => true` for server-filtered lists. |
| `id` | `string` |  |
| `inputValue` | `string` | Text being typed (controlled). Use it to fetch suggestions from a server. |
| `invalid` | `boolean` | Marks the value invalid. Inside a Field, the Field's `invalid` is used. |
| `max` | `number` | Maximum number of tags. Further additions are rejected with `messages.max`. |
| `messages` | `Partial<TagInputMessages>` | Override the built-in messages (rejections and screen reader announcements). |
| `name` | `string` | Form field name: one hidden input per tag is rendered with this name. |
| `normalize` | `((raw: string) => string)` | Cleans typed text before it is checked and added. Default: trims whitespace. |
| `onInputValueChange` | `((text: string) => void)` |  |
| `onValueChange` | `((tags: string[]) => void)` | Called with the full new list whenever a tag is added or removed. |
| `removeLabel` | `((tag: string) => string)` | Accessible name of each tag's remove button. Default `Remove <tag>`. |
| `separators` | `string[]` | Characters that commit the typed text as a tag, besides Enter. They also split pasted text, together with new lines and tabs. Default `[',']`. |
| `size` | `"sm" \| "md" \| "lg"` |  |
| `suggestions` | `string[]` | Values offered in a listbox while typing. Already added tags are left out. |
| `validate` | `((tag: string, tags: readonly string[]) => string \| null \| undefined)` | Checks a tag before it is added. Return a message to reject it (shown under the field and announced), or nothing to accept it. |
| `value` | `string[]` | The tags (controlled). |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `size` | `'sm'``'md'``'lg'` | Minimum height of the control from `--mn-control-*`. The control grows as chips wrap. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Rejected entry | `aria-invalid="true" on the input` | The message appears under the field and is linked with `aria-describedby`; it clears when the user types. |
| Invalid | `data-invalid on the control` | Danger border, from `invalid`, the surrounding Field or a rejected entry. |
| Disabled | `data-disabled on the control, disabled on the input` | Chips stay visible; remove buttons are not rendered. |
| Read-only | `readonly on the input` | Chips are shown without remove buttons and nothing can be added. |
| Suggestions open | `aria-expanded="true" on the input` | A listbox of matching suggestions below the field. |

## Interaction behaviour

- Enter or a separator (default comma) adds the typed text after `normalize`, the duplicate check (case-insensitive), `max` and `validate`.
- Pasting text that contains a separator, a new line or a tab adds each part; parts that are rejected stay in the field, joined by commas.
- Clicking a chip’s remove button removes it and returns focus to the input.
- Additions and removals are announced in a polite status region ("Added Research.").
- With `suggestions`, typing opens a listbox of matches that are not already added; clicking one adds it.
- `addOnBlur` commits the typed text when focus leaves the field.
- Controlled with `value` + `onValueChange` (and optionally `inputValue` + `onInputValueChange`), or uncontrolled with `defaultValue`.

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter | Adds the typed text, or the highlighted suggestion. |
| , (or any `separators` key) | Adds the typed text. |
| Backspace (empty field) | Moves focus to the last chip’s remove button; Backspace again removes it. |
| ArrowLeft (caret at start) | Moves focus to the last chip. |
| ArrowLeft / ArrowRight (on a chip) | Moves between chips; ArrowRight past the last chip returns to the input. |
| Backspace / Delete (on a chip) | Removes it and focuses the previous chip (Backspace) or the next one (Delete). |
| ArrowDown / ArrowUp | Opens and moves through suggestions. |
| Escape | Closes the suggestions. |

## Accessibility

Role

textbox, or combobox with a listbox when

suggestions

is set; chips are a list

Accessible name

A FieldLabel or

aria-label

on the field. Each remove button is named by

removeLabel

(default "Remove <tag>").

- Chips are list items, so screen readers announce how many there are.
- Remove buttons are real buttons in the Tab order, so the arrow-key shortcuts are a convenience, not the only way.
- Suggestions follow the APG combobox pattern: the input keeps focus and points at the highlighted option with `aria-activedescendant`.
- Rejection messages are linked to the input and announced; additions and removals are announced in a separate status region.

## Responsive behaviour

- Chips wrap onto new lines and the control grows; the text input keeps at least 6rem so there is always room to type.
- Long tags are truncated with an ellipsis inside their chip.

## Theme support

- Chips use `--mn-color-bg-muted` and `--mn-color-border-subtle`; the message uses `--mn-color-danger-strong`. All are semantic tokens, so light, dark and more-contrast themes apply.
- Remove-button hover transitions are removed under `prefers-reduced-motion`.

## Do and don't

### Do

- Say in a FieldDescription how values are separated when it is not just commas.
- Write rejection messages that say what is wrong with that value.
- Pass `normalize` when case or spacing should not create a new tag.

### Don't

- Do not use it for a fixed set of options; a multi-select makes the choices visible.
- Do not hide the reason a value was rejected; return a message from `validate`.

## Related components

- [Combobox](https://monarchui.dev/components/combobox/): Pick a single value from a list.
- [Input](https://monarchui.dev/components/text-field/): A single free-text value.
- Filter Chips (stable): Chips that show active filters rather than edit a value.
- [Field](https://monarchui.dev/components/form-field/): Label, help and error wiring.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react``@radix-ui/react-popover` |
| Monarch components | [Field](https://monarchui.dev/components/form-field/) |

Uses the `.mn-input` styles from text-field for the control and `.mn-menu` for suggestions.

## Testing coverage

- Adds on Enter and comma; trims and ignores blank entries; labels remove buttons; announces additions.
- Splits pasted lists; leaves pastes without separators to the browser.
- A validator rejection keeps the text, marks the input invalid and links the message; typing clears it.
- Rejects duplicates case-insensitively and enforces `max`.
- Backspace moves to the last chip, then removes it; arrows move between chips; Delete removes; focus lands sensibly.
- Click removal returns focus to the input.
- Suggestions: listbox, already-added values left out, arrow navigation with `aria-activedescendant`, Enter and click to add, Escape to close.
- Controlled tags and typed text; `addOnBlur`; hidden inputs for `name`.
- Disabled (from Field) and read-only render no remove buttons.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery; Cross-browser axe: /components/tag-input |

Tests: `packages/react/src/components/forms/tag-input`

## All example code

### Skills with suggestions

```tsx
import { Field, FieldLabel, TagInput } from '@rookcom/monarch-react';

const SKILLS = [
  'Accessibility',
  'Copywriting',
  'Data analysis',
  'Illustration',
  'Research',
  'TypeScript',
];

<div style={{ width: '100%', maxWidth: 300 }}>
  <Field>
    <FieldLabel>Skills</FieldLabel>
    <TagInput
      defaultValue={['Research', 'TypeScript']}
      suggestions={SKILLS}
      max={8}
      placeholder="Add a skill"
    />
  </Field>
</div>
```

### Validated emails

```tsx
import { useState } from 'react';
import { Field, FieldDescription, FieldLabel, TagInput } from '@rookcom/monarch-react';

const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);

function RecipientsDemo() {
  const [cc, setCc] = useState(['sam@example.com']);
  return (
    <Field>
      <FieldLabel>Cc</FieldLabel>
      <TagInput
        value={cc}
        onValueChange={setCc}
        separators={[',', ' ', ';']}
        validate={(v) => (isEmail(v) ? null : `“${v}” is not an email address.`)}
        normalize={(v) => v.trim().toLowerCase()}
        placeholder="Add addresses"
      />
      <FieldDescription>Separate with commas or spaces.</FieldDescription>
    </Field>
  );
}

<div style={{ width: '100%', maxWidth: 300 }}>
  <RecipientsDemo />
</div>
```

### Read-only and small

```tsx
import { TagInput } from '@rookcom/monarch-react';

<div style={{ display: 'grid', gap: 12, width: '100%', maxWidth: 300 }}>
  <TagInput aria-label="Labels" readOnly defaultValue={['Billing', 'Priority']} />
  <TagInput aria-label="Keywords" size="sm" defaultValue={['quarterly', 'draft']} />
</div>
```
