Tags

A field that collects a list. Type, press Enter, and the text becomes a tag you can remove.

(Optional)
  • Carrots
  • Onions
  • Tomatoes
Enter or a comma adds one. Backspace on an empty field removes the last.
<TagsInput
  label="Ingredients"
  tagsPosition="outside"
  value={tags}
  onChange={setTags}
  placeholder="Type and press Enter"
/>

Two placements

Not a taste setting: they are two different jobs. Outside is what the design draws: the field stays one line forever and the list grows downward, so a form holding twenty tags does not reflow every time you add one. Inside puts them ahead of the caret, which reads as “these are the value” and is right when there will be three of them, not thirty.

tagsPositionWhereWhy
outsideA row under the fieldFigma 6:4209. The field keeps one line forever.
insideAhead of the caretReads as "these are the value". The field grows a line at a time.

Adding and removing

Enter or a comma commits; commitKeys changes that. Leaving the field commits too, because losing a half-typed tag to a click elsewhere is the most annoying bug this component can have. Backspace on an empty field removes the last tag , the one behaviour everybody tries and most implementations miss, and it is guarded on the field being empty so it never eats a tag while you are still typing one.

Duplicates are dropped silently rather than rejected loudly: you typed something already in the list, the list is already correct.

recipe-form.tsx
const [ingredients, setIngredients] = useState<string[]>([])

<TagsInput
  label="Ingredients"
  value={ingredients}
  onChange={setIngredients}
  validate={(tag) => tag.length <= 24}
  maxTags={12}
/>

Props

PropTypeDefaultNotes
valuestring[]—The tags, controlled.
defaultValuestring[][]The tags, uncontrolled.
onChange(value: string[]) => void—Receives the next array, never the event.
tagsPosition'outside' | 'inside''outside'Where the tags sit.
inputValuestring—The text in the field, if you want to drive it.
onInputValueChange(value: string) => void—Fires as that text changes.
commitKeysstring[]['Enter', ',']Keys that turn the text into a tag.
validate(tag, tags) => boolean—Return false and the text stays in the field.
maxTagsnumber—Stops accepting past this many.
removeLabel(tag: string) => string"Remove {tag}"Accessible name for each remove button.
listLabelstring'Selected'Accessible name for the list the tags sit in.

Shared

The same contract as every other field, with one difference: value, defaultValue and onChange work in string[] rather than string, because the value of this field is a list. Those three are in the table above; the rest are unchanged.

PropTypeDefaultNotes
labelReactNode—Rendered as a real <label> wired by htmlFor.
hintReactNode—Helper text under the field.
errorReactNode—Error text. Implies invalid unless invalid says otherwise.
invalidboolean—Forces the error styling on or off.
errorKeystring | number—Change it to replay the error animation.
disabledbooleanfalseGreys the field out.
readOnlybooleanfalseReads as filled, stays focusable.
size'sm' | 'md' | 'lg''md'Size scale, where the component defines one.
classNamesRecord<Part, string>—Per-part class names.