TagsInput

Enter arbitrary strings, with the existing ones offered as suggestions.

import { ... } from '@corndev/ui'

Type anything and press Enter. Suggestions are only a shortcut.

openapi

openapi

scope:read

HTML

<div class="corn-tags-input">
  <div class="corn-pills-input" data-size="md">
    <span class="corn-pill" data-size="md">
      <span class="corn-pill-label">openapi</span>
      <button type="button" class="corn-pill-remove" aria-label="Remove openapi"></button>
    </span>
    <input class="corn-pills-input-field" role="combobox" aria-expanded="false" aria-label="Tags" />
  </div>
</div>

Classes

Class
.corn-tags-input

Vanilla JS

Required for behaviour — adds commits what you type on Enter, on a comma and when focus leaves the field, splits a pasted list, offers matching suggestions, and builds the pills and the listbox.

import { createTagsInput } from '@corndev/ui';
import '@corndev/ui/theme';

const el = document.querySelector('#target');
const instance = createTagsInput(el, { /* options */ });

Options

OptionTypeDefaultDescription
valueArray<string>undefinedThe tags (controlled). Pass it to update() to drive the field from outside.
defaultValueArray<string>undefinedInitial tags (uncontrolled).
onChange(tags: Array<string>) => voidundefinedCalled with the whole next array whenever it changes.
suggestionsArray<{label: string, value: *}|string>undefinedOffered while typing. Not a constraint — anything typed can be entered, which is the point of this component.
placeholderstringundefinedShown only while nothing is entered.
size'sm'|'md'|'lg''md'
disabledbooleanfalse
errorbooleanfalsePaints the field’s error state.
maxnumberundefinedStop accepting tags at this many.
validate(tag: string) => booleanundefinedRejects a tag before it is added. Whitespace is already trimmed and the empty string already refused.
acceptOnBlurbooleantrueAdd what was typed when focus leaves the field — by Tab or by clicking elsewhere — through the same rules as Enter, so validate and max still apply. On by default: text left in the field looks like a tag, and losing a value the user can see on screen is the worst way to lose one. false makes Enter the only commit.
splitCharsstring[][','Characters that separate tags, typed or pasted, so “reporting, maintenance” is two tags. A paste also splits on new lines, so a column from a spreadsheet becomes one tag per row. [] never splits, for tags that may contain a comma.

React

TagsInput — from @corndev/ui-react.

Controlled with value + onChange, or uncontrolled with defaultValue. validate rejects a tag the caller does not want, after trimming and the empty check. Leaving the field adds what was typed, through the same rules as Enter — text left in the field looks like a tag, and losing a value the user can see is the worst way to lose one; acceptOnBlur={false} makes Enter the only commit. A comma finishes a tag, and a paste splits on commas and new lines; splitChars changes the separators, and [] never splits.

Props

PropTypeDefaultDescription
valueArray<string>undefinedThe tags (controlled).
defaultValueArray<string>undefinedInitial tags (uncontrolled).
onChange(tags: Array<string>) => voidundefinedCalled with the whole next array whenever it changes.
suggestionsArray<{label: string, value: *}|string>undefinedOffered while typing. Not a constraint — anything the user types can be entered, which is the point of this component.
placeholderstringundefinedShown only while nothing is entered.
size'sm'|'md'|'lg''md'
disabledbooleanfalse
errorbooleanfalsePaints the field’s error state.
maxnumberundefinedStop accepting tags at this many.
validate(tag: string) => booleanundefinedRejects a tag before it is added. Whitespace is already trimmed and the empty string already refused, so this is for rules only the caller knows.
acceptOnBlurbooleantrueAdd what was typed when focus leaves the field — by Tab or by clicking elsewhere — through the same rules as Enter, so validate and max still apply. On by default: text left in the field looks like a tag, and losing a value the user can see on screen is the worst way to lose one. false makes Enter the only commit.
splitCharsstring[][','Characters that separate tags, typed or pasted, so “reporting, maintenance” is two tags. A paste also splits on new lines, so a column from a spreadsheet becomes one tag per row. [] never splits, for tags that may contain a comma.
clearablebooleanfalseShow a control that empties the field, calling onChange with an empty array. Escape clears too.
clearLabelstring'Clear tags'Accessible name for it.
classNamestringundefinedMerged with the Corn UI classes on the wrapper, which is what positions the listbox. Everything else — id, name, required, aria-label, aria-describedby — goes to the input, not the wrapper, as it does on Combobox. That is what lets a Field around this wire its label and its description to the thing a screen reader actually lands on.

TagsInput or MultiSelect?

They look almost identical and they mean different things to the data underneath, so picking the wrong one is a modelling error rather than a cosmetic one.

TagsInputThe user extends the vocabulary. suggestions are only a shortcut for typing.
MultiSelectThe options are the vocabulary. A value outside them cannot be entered.

Choose a tags input for an open set — a document’s tags, an enum’s values, OAuth scopes. Choose a multi-select when nothing outside the list is valid.

Three rules worth knowing

They are small, and leaving any of them undefined is how two tag lists in one app end up disagreeing.

A tag is trimmed. " openapi " becomes openapi, because a tag with a trailing space is the same tag and nobody meant to make two.

The empty string is refused. Enter on a blank field, or on a run of spaces, does nothing.

Re-entering an existing tag is silently ignored. It does not move to the end, and it is not an error. A tag list is a set, so the user’s intent — “this should be in here” — is already satisfied; reordering would be a surprise and an error message would be a scolding for asking for something already true. The field still clears, because leaving the text behind would read as a failure.

Suggestions are not a constraint

Anything typed can be entered. A suggestion is a shortcut, and it leaves the list once entered — unlike a multi-select option, which stays so a second click un-picks it. Offering a shortcut to something already done is a row that does nothing.

When nothing matches, no list opens at all. There is nothing useful to say when the user can type whatever they like.

openapi
<TagsInput
  suggestions={['openapi', 'asyncapi', 'graphql']}
  defaultValue={['openapi']}
  aria-label="Tags"
/>

Leaving the field adds what you typed

Type a tag and Tab away, or click Save, and the tag is added — through the same rules as Enter, so validate and max still apply. Text left sitting in the field looks like a tag, and finding out it wasn’t when the document saves without it is the worst way to lose a value.

acceptOnBlur={false} makes Enter the only commit, if clicking away to check something should leave a half-typed tag alone.

Commas and pasting

Typing a comma finishes a tag, so reporting, maintenance is two. Pasting splits on commas and new lines, so a column copied from a spreadsheet becomes one tag per row. Every piece goes through the same rules — trimmed, duplicates ignored, validate and max applied across the whole paste.

<TagsInput splitChars={[',', ';']} />   // split on either
<TagsInput splitChars={[]} />           // never split — a tag may contain a comma

The keyboard

TypeFilters the suggestions
↓ ↑Move the cursor over the suggestions
EnterAdds the highlighted suggestion, or what you typed
,Finishes the tag before it
Tab, or clicking awayAdds what you typed, unless acceptOnBlur={false}
BackspaceOn an empty field, removes the last tag
EscCloses the suggestions

Every tag’s remove button is a real <button> named for the tag it removes, so a keyboard or screen-reader user can tell them apart.

Your own rules

validate runs after the trim and the empty check, so it only has to know what you know.

scope:read
<Field label="Lowercase scopes" description="Uppercase is refused.">
  <TagsInput
    defaultValue={['scope:read']}
    validate={tag => tag === tag.toLowerCase()}
  />
</Field>

Clearing the field

clearable shows a control that empties the field, and only when there is something to empty. It calls onChange with an empty array.

openapigraphql
<TagsInput defaultValue={['openapi', 'graphql']} clearable />

Escape clears too, while the field has focus and the list is shut. When the list is open Escape closes it instead — that is what Escape means when something is in the way.

The control is deliberately not in the tab order. A field that tabs to its input, then its clear control, then its list is tedious to escape, and the keyboard already has Escape. The control is for the pointer.

Its accessible name says what it clears rather than just “Clear”, because a form with several of these otherwise announces the same button three times. Its pointer target is 24px even though the glyph is 1rem — the shared target rule does that.