{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chip-input","type":"registry:ui","title":"Chip Input","description":"A tag field where Enter or a comma pops text into a chip, the others slide aside when one leaves, a duplicate shakes the existing chip, and Backspace marks then removes the last one.","dependencies":["motion","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/chip-input.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion, useAnimate } from \"motion/react\";\nimport { useId, useRef, useState, type KeyboardEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ChipInputProps {\n  label?: string;\n  /** Controlled chips. */\n  value?: string[];\n  defaultValue?: string[];\n  onChange?: (chips: string[]) => void;\n  placeholder?: string;\n  /** Maximum number of chips. */\n  max?: number;\n  className?: string;\n}\n\n/**\n * A tag field: Enter or a comma turns the text into a chip that pops in, the\n * other chips slide aside when one leaves, a duplicate shakes the chip that\n * already exists, and Backspace on an empty field marks then removes the last one.\n */\nexport function ChipInput({\n  label = \"Tags\",\n  value,\n  defaultValue = [],\n  onChange,\n  placeholder = \"Add a tag…\",\n  max = 12,\n  className,\n}: ChipInputProps) {\n  const id = useId();\n  const [inner, setInner] = useState(defaultValue);\n  const [text, setText] = useState(\"\");\n  const [armed, setArmed] = useState(false);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const input = useRef<HTMLInputElement>(null);\n  const chips = value ?? inner;\n  const full = chips.length >= max;\n\n  const commit = (next: string[]) => {\n    if (value === undefined) setInner(next);\n    onChange?.(next);\n  };\n\n  const add = () => {\n    const t = text.trim().replace(/,+$/, \"\");\n    if (!t || full) return;\n    const dupe = chips.findIndex((c) => c.toLowerCase() === t.toLowerCase());\n    if (dupe >= 0) {\n      const el = scope.current?.querySelector(`[data-chip=\"${dupe}\"]`);\n      if (el && !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches)\n        animate(el, { x: [0, -5, 5, -3, 3, 0] }, { duration: 0.35 });\n      return;\n    }\n    commit([...chips, t]);\n    setText(\"\");\n  };\n\n  const remove = (i: number) => {\n    commit(chips.filter((_, j) => j !== i));\n    setArmed(false);\n    input.current?.focus();\n  };\n\n  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\" || e.key === \",\") {\n      e.preventDefault();\n      add();\n    } else if (e.key === \"Backspace\" && !text && chips.length) {\n      e.preventDefault();\n      if (armed) remove(chips.length - 1);\n      else setArmed(true);\n    } else if (armed) setArmed(false);\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"grid gap-1.5\", className)}>\n        <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n          {label}\n        </label>\n        <div\n          ref={scope}\n          onClick={() => input.current?.focus()}\n          className=\"flex min-h-10 flex-wrap items-center gap-1.5 rounded-lg border border-border bg-background p-1.5 transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {chips.map((c, i) => (\n              <motion.span\n                layout\n                key={c}\n                data-chip={i}\n                className={cn(\n                  \"inline-flex h-7 items-center gap-1 rounded-md pl-2.5 pr-1 text-xs font-medium transition-colors\",\n                  armed && i === chips.length - 1 ? \"bg-destructive/15 text-destructive\" : \"bg-muted text-foreground\",\n                )}\n                initial={{ opacity: 0, scale: 0.6, filter: \"blur(3px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, scale: 0.6, filter: \"blur(3px)\", transition: { duration: 0.15 } }}\n                transition={{ type: \"spring\", bounce: 0.35, duration: 0.35 }}\n              >\n                {c}\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${c}`}\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    remove(i);\n                  }}\n                  className=\"grid size-5 place-items-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                >\n                  <X className=\"size-3\" />\n                </button>\n              </motion.span>\n            ))}\n          </AnimatePresence>\n          <motion.input\n            layout\n            id={id}\n            ref={input}\n            value={text}\n            onChange={(e) => {\n              setText(e.target.value);\n              setArmed(false);\n            }}\n            onKeyDown={onKey}\n            onBlur={add}\n            disabled={full}\n            placeholder={full ? `Up to ${max}` : placeholder}\n            className=\"h-7 min-w-24 flex-1 bg-transparent px-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n          />\n        </div>\n        <p className=\"text-xs text-muted-foreground\">Enter or comma to add · Backspace twice to remove the last</p>\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}