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…

"use client"

import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Hoisted via React 19 <style href precedence> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes zti-shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-3px)}75%{transform:translateX(3px)}}`

export interface TagsInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
  /** Controlled list of tags — the component never owns it. */
  value: string[]
  onChange: (tags: string[]) => void

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tags-input.json

Prompt

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 budgetmaxTags shows 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.

On This Page