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
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
| Option | Type | Default | Description |
|---|---|---|---|
value | Array<string> | undefined | The tags (controlled). Pass it to update() to drive the field from outside. |
defaultValue | Array<string> | undefined | Initial tags (uncontrolled). |
onChange | (tags: Array<string>) => void | undefined | Called with the whole next array whenever it changes. |
suggestions | Array<{label: string, value: *}|string> | undefined | Offered while typing. Not a constraint — anything typed can be entered, which is the point of this component. |
placeholder | string | undefined | Shown only while nothing is entered. |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
error | boolean | false | Paints the field’s error state. |
max | number | undefined | Stop accepting tags at this many. |
validate | (tag: string) => boolean | undefined | Rejects a tag before it is added. Whitespace is already trimmed and the empty string already refused. |
acceptOnBlur | boolean | true | Add 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. |
splitChars | string[] | [',' | 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | Array<string> | undefined | The tags (controlled). |
defaultValue | Array<string> | undefined | Initial tags (uncontrolled). |
onChange | (tags: Array<string>) => void | undefined | Called with the whole next array whenever it changes. |
suggestions | Array<{label: string, value: *}|string> | undefined | Offered while typing. Not a constraint — anything the user types can be entered, which is the point of this component. |
placeholder | string | undefined | Shown only while nothing is entered. |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
error | boolean | false | Paints the field’s error state. |
max | number | undefined | Stop accepting tags at this many. |
validate | (tag: string) => boolean | undefined | Rejects 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. |
acceptOnBlur | boolean | true | Add 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. |
splitChars | string[] | [',' | 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. |
clearable | boolean | false | Show a control that empties the field, calling onChange with an empty array. Escape clears too. |
clearLabel | string | 'Clear tags' | Accessible name for it. |
className | string | undefined | Merged 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.
| TagsInput | The user extends the vocabulary. suggestions are only a shortcut for typing. |
| MultiSelect | The 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.
<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
| Type | Filters the suggestions |
| ↓ ↑ | Move the cursor over the suggestions |
| Enter | Adds the highlighted suggestion, or what you typed |
| , | Finishes the tag before it |
| Tab, or clicking away | Adds what you typed, unless acceptOnBlur={false} |
| Backspace | On an empty field, removes the last tag |
| Esc | Closes 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.
<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.
<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.