Inputs
Tags Input
A chips input for free-form multi-value entry — Enter or comma commits, paste splits, duplicates shake, maxTags caps with a counter.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/tags-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TagsInput" component (lucide-react X).
Contract
- Export a forwardRef component whose ref points at the inner <input>;
props extend InputHTMLAttributes minus value/onChange.
- Fully controlled: value (string[]) + onChange (tags: string[]) => void —
the component owns only the in-progress draft text, never the tag list.
- Options: placeholder?, maxTags? (hard cap), allowDuplicates? (default
false), disabled?, className (applied to the container).
- Remaining native input props spread onto the inner input; clicking
anywhere on the container focuses the input.
Behavior
- Commit boundaries: Enter or comma commits the draft (preventDefault so
neither submits a form nor types a comma). Commit = split on commas,
trim each token, drop empties, append what fits.
- Skip commit handling while an IME composition is active
(e.nativeEvent.isComposing) so CJK input is never broken.
- Duplicates (when allowDuplicates is false): the existing matching chip
shakes briefly and the typed text is kept, so nothing the user wrote is
lost; the shake clears itself in onAnimationEnd (no timers).
- Backspace in an empty input removes the last tag.
- Every chip renders an X button with aria-label "Remove <tag>" that
removes exactly that chip.
- Paste: if the clipboard text contains a comma, intercept it, merge with
the current draft, tokenize, and batch-add — truncating at maxTags.
- maxTags: once value.length reaches it, the inner input becomes disabled
(placeholder hidden) and an "N/max" counter is shown; the counter is
visible whenever maxTags is set and is aria-live="polite". Removing a
chip re-enables the input.
- disabled: container dims, input and every X button are disabled.
Rendering & styling
- Container mimics the shadcn input: flex flex-wrap min-h-9 rounded-md
border border-input bg-background, focus-within:border-ring +
focus-within:ring-ring/50 so it focuses as one field.
- Chips: bg-secondary text-secondary-foreground rounded-md, text
truncates (min-w-0 max-w-full) so long tags never overflow the field.
- Counter: text-muted-foreground tabular-nums. Semantic tokens only —
no hardcoded colors; dark mode is free.
- Ship the shake @keyframes via a React 19 hoisted
<style href precedence="medium"> tag; the shake is
motion-reduce:[animation:none].
- X buttons are real <button type="button"> with a focus-visible ring;
merge the consumer className via cn().
Customization levers
- Chip color: swap bg-secondary/text-secondary-foreground for
bg-primary/10 text-primary (subtle accent) or bg-muted
text-muted-foreground (quieter) — keep bg and foreground paired.
- Separator: change comma to semicolon (or both) by editing the commit
key check and the split delimiter — they are the only two places.
- react-hook-form: wrap in a Controller and map the field directly —
value={field.value ?? []} onChange={field.onChange}; validate with a
zod z.array(z.string()).max(n) schema instead of relying on maxTags UI.
- Density: min-h-9 / px-2.5 / gap-1.5 on the container and text-xs on
chips are the sizing knobs; scale them together.
- Normalization: lowercase or slugify tokens inside the tokenize step if
tags must be canonical (dedupe then happens on the normalized form).Concepts
- Commit boundaries — free text becomes a tag only at explicit moments (Enter, comma, comma-bearing paste), so the component never guesses mid-word; IME composition is exempt so CJK typing works.
- Controlled value, local draft — the tag list lives in the consumer as
string[]; the component owns nothing but the in-progress text, which makes form-library integration a direct field mapping. - Duplicate guard with feedback — a rejected duplicate shakes the chip that already holds the value and preserves the typed text, telling the user both that it failed and where the existing tag is.
- Paste tokenization — one paste of "a, b, c" runs through the same tokenizer as typing, so bulk entry and manual entry can never drift apart.
- Cap as visible budget —
maxTagsshows an "N/max" counter the whole time, then locks the input at the cap; deleting a chip reopens it, so the limit is discoverable rather than a silent swallow. - Two deletion paths — a mouse path (per-chip X button, labelled
Remove <tag>) and a keyboard path (Backspace on empty input) keep editing fluent in both modalities.