Components · Forms
TagInput
Chips for a list of short values: Enter or comma to add, paste to split, keys to remove.
Skills with suggestions
- Research
- TypeScript
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>Usage examples
Validated emails
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(['[email protected]']);
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
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>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:
normalizeandvalidatedecide 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
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),maxandvalidate. - 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. addOnBlurcommits the typed text when focus leaves the field.- Controlled with
value+onValueChange(and optionallyinputValue+onInputValueChange), or uncontrolled withdefaultValue.
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
suggestionsis set; chips are a list - Accessible name
- A FieldLabel or
aria-labelon the field. Each remove button is named byremoveLabel(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-mutedand--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
normalizewhen 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
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react@radix-ui/react-popover |
| Monarch components | 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 forname. - 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