{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "title": "Tag Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Enter, paste, and remove text tags with duplicate prevention, limits, and keyboard shortcuts.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/tag-input.tsx",
      "content": "// Ballmac UI: Tag Input. https://ui.ballmac.com/components/tag-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype TagInputProps = Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  /** Controlled tag list. */\n  value?: string[]\n  /** Initial tag list when uncontrolled. */\n  defaultValue?: string[]\n  /** Called with the complete list after edits. */\n  onValueChange?: (value: string[]) => void\n  /** Maximum number of tags. */\n  maxTags?: number\n  /** Maximum characters per tag. */\n  maxLength?: number\n  /** Placeholder shown when the list is empty. */\n  placeholder?: string\n  /** Accessible name for the text field. */\n  label?: string\n  /** Name used for repeated hidden form inputs. */\n  name?: string\n  /** Disable editing. */\n  disabled?: boolean\n}\nfunction TagInput({\n  value,\n  defaultValue = [],\n  onValueChange,\n  maxTags = 8,\n  maxLength = 32,\n  placeholder,\n  label,\n  name,\n  disabled = false,\n  className,\n  ...props\n}: TagInputProps) {\n  const msg = useMessages()\n  placeholder ??= msg(\"tag-input.placeholder\", \"Add a tag…\")\n  label ??= msg(\"tag-input.label\", \"Tags\")\n  const [internal, setInternal] = React.useState(defaultValue)\n  const [draft, setDraft] = React.useState(\"\")\n  const [message, setMessage] = React.useState(\"\")\n  const tags = value ?? internal\n  const id = React.useId()\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  function commit(next: string[]) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  function add(raw: string) {\n    const tag = raw.trim().slice(0, maxLength)\n    if (!tag) return\n    if (\n      tags.some(\n        (entry) =>\n          entry.toLocaleLowerCase(\"en-US\") === tag.toLocaleLowerCase(\"en-US\"),\n      )\n    ) {\n      setMessage(\"That tag is already added.\")\n      return\n    }\n    if (tags.length >= maxTags) {\n      setMessage(`You can add up to ${maxTags} tags.`)\n      return\n    }\n    commit([...tags, tag])\n    setDraft(\"\")\n    setMessage(\"\")\n  }\n  return (\n    <div\n      data-slot=\"tag-input\"\n      className={cn(\"w-full min-w-0\", className)}\n      {...props}\n    >\n      <div\n        className=\"flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5 shadow-xs transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[input[aria-invalid=true]]:border-destructive\"\n        onClick={() => inputRef.current?.focus()}\n      >\n        {tags.map((tag, index) => (\n          <span\n            key={`${tag}-${index}`}\n            className=\"inline-flex max-w-full items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground\"\n          >\n            <span className=\"truncate\">{tag}</span>\n            <button\n              type=\"button\"\n              disabled={disabled}\n              aria-label={msg(\"tag-input.remove\", \"Remove {tag}\", { tag })}\n              onClick={(event) => {\n                event.stopPropagation()\n                commit(tags.filter((_, position) => position !== index))\n                inputRef.current?.focus()\n              }}\n              className=\"flex size-5 items-center justify-center rounded-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50\"\n            >\n              <X aria-hidden=\"true\" className=\"size-3\" />\n            </button>\n          </span>\n        ))}\n        <input\n          ref={inputRef}\n          data-slot=\"tag-input-field\"\n          aria-label={label}\n          aria-describedby={message ? id : undefined}\n          disabled={disabled}\n          value={draft}\n          placeholder={tags.length ? \"\" : placeholder}\n          onChange={(event) => setDraft(event.target.value)}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\" || event.key === \",\") {\n              event.preventDefault()\n              add(draft)\n            }\n            if (event.key === \"Backspace\" && !draft && tags.length)\n              commit(tags.slice(0, -1))\n          }}\n          onPaste={(event) => {\n            const text = event.clipboardData.getData(\"text\")\n            if (text.includes(\",\")) {\n              event.preventDefault()\n              const next = [...tags]\n              for (const raw of text.split(\",\")) {\n                const tag = raw.trim().slice(0, maxLength)\n                if (\n                  tag &&\n                  next.length < maxTags &&\n                  !next.some(\n                    (entry) =>\n                      entry.toLocaleLowerCase(\"en-US\") ===\n                      tag.toLocaleLowerCase(\"en-US\"),\n                  )\n                )\n                  next.push(tag)\n              }\n              if (next.length !== tags.length) commit(next)\n              setDraft(\"\")\n            }\n          }}\n          className=\"min-w-24 flex-1 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50\"\n        />\n      </div>\n      {name &&\n        tags.map((tag, index) => (\n          <input\n            key={`${tag}-${index}`}\n            type=\"hidden\"\n            name={name}\n            value={tag}\n          />\n        ))}\n      <p\n        id={id}\n        className=\"mt-1.5 text-xs text-muted-foreground\"\n        aria-live=\"polite\"\n      >\n        {message ||\n          `${tags.length} of ${maxTags} tags · Enter to add, Backspace to remove`}\n      </p>\n    </div>\n  )\n}\nexport { TagInput, type TagInputProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/tag-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "tags",
      "chips",
      "text-input"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Enter, paste, and remove text tags with duplicate prevention, limits, and keyboard shortcuts.",
      "whenToUse": [
        "Enter free-form labels or skills",
        "Edit a small set of keywords"
      ],
      "whenNotToUse": [
        "Use multi-select for a fixed set of options"
      ],
      "composesWith": [
        "input"
      ],
      "a11y": [
        {
          "keys": "Enter / comma",
          "action": "Adds the typed tag"
        },
        {
          "keys": "Backspace",
          "action": "Removes the last tag when empty"
        },
        {
          "keys": "Tab / Enter",
          "action": "Focuses and activates remove buttons"
        }
      ],
      "customization": [
        "maxTags, maxLength",
        "value / defaultValue and onValueChange"
      ]
    },
    "examples": [
      "tag-input-demo",
      "tag-input-states"
    ],
    "url": "https://ui.ballmac.com/components/tag-input"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}