Tag Input
Enter free-form values as chips, with validation and duplicate removal.
import { TagInput } from '@youj/react'
Installation & usage
npm i @youj/reactFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Use a tag input for values people make up: repository topics, allowed email domains, keywords, recipients. Nothing is chosen from a list. For values from a known list, use a Multi-select, which searches and groups them.
import { Field, FieldLabel, TagInput } from '@youj/react'
export function TopicsField() {
const [topics, setTopics] = useState(['react', 'motion'])
return (
<Field>
<FieldLabel>Topics</FieldLabel>
<TagInput
value={topics}
onValueChange={setTopics}
validate={(tag) => (/^[a-z0-9-]+$/.test(tag) ? undefined : 'Use lowercase letters, numbers and hyphens.')}
maxTags={8}
/>
</Field>
)
}The field is an Input Group whose text input sits inline after the chips and wraps with them. Enter or a comma adds what you typed. delimiters changes the keys besides Enter, and addOnBlur (on by default) adds the text when the field is left.
Description
A Topics field holding three tags, validated as lowercase words and capped at eight.
validate receives the new tag and the current list, and returns a reason to reject it, or nothing to accept it. A rejected tag stays in the field so it can be fixed: the frame shakes, its border turns destructive, and the reason grows in under it and is read out. A duplicate isn't added. The chip that already holds it flashes instead, so you can see where it is. Try "motion" or "UI Kit" in the demo.
maxTags caps the list, and the field stops accepting new tags at the limit.
Chips can be reordered from the keyboard: focus one and press Alt with an arrow key to move it. Moves are announced, and the chips glide to their new places.
Pasting a list of values into the field.
Paste "alpha, beta, gamma", or a list with one item per line, and each becomes a chip. Commas, new lines, tabs and semicolons all separate. Items that fail validate stay in the field with the reason.
Inviting people by email, three addresses already added.
Each address is checked by validate as it's added, so a typo stays in the field with the reason instead of becoming a chip. maxTags caps one batch of invites, and name submits every address with the form.
Code
tag-input-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsvalue, defaultValue, onValueChange… (15)
Props
| Prop | Type | |
|---|---|---|
value | string[] | – |
defaultValue | string[]default [] | [] |
onValueChange | (value: string[]) => void | – |
validate | (tag: string, tags: string[]) => string | null | undefined | voidReturns a reason to reject a tag, or nothing to accept it. | – |
delimiters | string[]Keys that commit the typed text, besides Enter. default [','] | [','] |
addOnBlur | booleanAdds the typed text when the field loses focus. default true | true |
maxTags | numberThe most tags; the field stops accepting new ones at the limit. | – |
placeholder | string | – |
disabled | boolean | – |
name | stringAlso submits each tag with a native form, under this name. | – |
id | string | – |
ref | Ref<HTMLInputElement> | – |
onBlur | () => void | – |
className | string | – |
aria-label | string | – |
MotionChip enters, chip leaves, chips reflow or reorder… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Chip entersfades down into its slot (PresenceList) | duration.baseease.enter220ms · cubic-bezier(0.16, 1, 0.3, 1) | 220mscubic-bezier(0.16, 1, 0.3, 1) | |
| Chip leavesfades where it stands | duration.fastease.exit140ms · cubic-bezier(0.4, 0, 1, 1) | 140mscubic-bezier(0.4, 0, 1, 1) | |
| Chips reflow or reorderglide to their new places | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| Rejectedthe frame shakes a decaying 4, 3 and 2px | duration.slowease.standard340ms · cubic-bezier(0.2, 0, 0, 1) | 340mscubic-bezier(0.2, 0, 0, 1) | |
| Reasongrows in under the field (Collapse) | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) | |
| Duplicate flashthe existing chip tints at once, then fades back | duration.slowease.standard340ms · cubic-bezier(0.2, 0, 0, 1) | 340mscubic-bezier(0.2, 0, 0, 1) |
Reduced motion
No shake and no glide: chips appear, leave and reorder in one frame. The duplicate flash and the reason still show, so the feedback survives.
Reduced motion
The rejection doesn't shake and chips don't glide. The duplicate chip still flashes and the reason still appears, because they carry the message rather than decorate it.
AccessibilityKeyboard, ARIA, focus
Accessibility
- The text input is named by its
FieldLabel, or byaria-label. While a tag is rejected, it carriesaria-invalidand is described by the reason, which is arole="alert"message and is read out at once. - In the field, Backspace when empty, or ArrowLeft at the start, reaches the last chip. On a chip, arrows move between chips, Alt with an arrow moves the chip itself, Home and End jump to the ends, Backspace or Delete removes it and focus lands on its neighbor, and Escape returns to the field.
- Each chip's remove button is named after its tag. Additions, removals, moves and duplicates are announced politely.
- With
name, each tag is submitted with a native form under that name.