{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "clean-tag-input",
  "type": "registry:ui",
  "title": "Clean Tag Input",
  "description": "iOS 26 liquid-glass tag input with spring-animated pill chips and keyboard-driven add/remove.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/clean-tag-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/* ── sound ── */\nlet _a: AudioContext, _b: AudioBuffer;\nconst tick = () => {\n  if (typeof window === \"undefined\") return;\n  if (!_a) {\n    _a = new AudioContext();\n    _b = _a.createBuffer(1, (_a.sampleRate * 0.003) | 0, _a.sampleRate);\n    const d = _b.getChannelData(0);\n    for (let i = 0; i < d.length; i++)\n      d[i] = (Math.random() * 2 - 1) * (1 - i / d.length) ** 4;\n  }\n  const s = _a.createBufferSource();\n  s.buffer = _b;\n  const g = _a.createGain();\n  g.gain.value = 0.08;\n  s.connect(g).connect(_a.destination);\n  s.start();\n};\n\n/* ── theme ── */\nconst CSS = `\n.ti{\n  --ti-glass:linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.62));\n  --ti-border:rgba(0,0,0,0.06);\n  --ti-shadow:0 0 1px rgba(0,0,0,0.04),0 2px 8px rgba(0,0,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);\n  --ti-dim:rgba(0,0,0,0.42);\n  --ti-mid:rgba(0,0,0,0.55);\n  --ti-hi:rgba(0,0,0,0.88);\n  --ti-focus:rgba(0,0,0,0.12);\n  --ti-tag:rgba(0,0,0,0.04);\n  --ti-tag-border:rgba(0,0,0,0.06);\n  --ti-tag-h:rgba(0,0,0,0.07);\n  --ti-x:rgba(0,0,0,0.3);\n  --ti-x-h:rgba(0,0,0,0.6)\n}\n.dark .ti,[data-theme=\"dark\"] .ti{\n  --ti-glass:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));\n  --ti-border:rgba(255,255,255,0.07);\n  --ti-shadow:0 1px 3px rgba(0,0,0,0.08),inset 0 1px 0 rgba(255,255,255,0.04);\n  --ti-dim:rgba(255,255,255,0.28);\n  --ti-mid:rgba(255,255,255,0.5);\n  --ti-hi:rgba(255,255,255,0.88);\n  --ti-focus:rgba(255,255,255,0.12);\n  --ti-tag:rgba(255,255,255,0.04);\n  --ti-tag-border:rgba(255,255,255,0.06);\n  --ti-tag-h:rgba(255,255,255,0.07);\n  --ti-x:rgba(255,255,255,0.3);\n  --ti-x-h:rgba(255,255,255,0.6)\n}`;\n\n/* ── types ── */\ninterface Tag {\n  id: string;\n  text: string;\n}\n\nexport interface CleanTagInputProps {\n  label?: string;\n  hint?: string;\n  placeholder?: string;\n  defaultTags?: string[];\n  onChange?: (tags: string[]) => void;\n  sound?: boolean;\n  style?: React.CSSProperties;\n}\n\n/* ── component ── */\nexport default function CleanTagInput({\n  label = \"Tags\",\n  hint,\n  placeholder = \"Type and press enter\",\n  defaultTags = [],\n  onChange,\n  sound = true,\n  style,\n}: CleanTagInputProps) {\n  const [tags, setTags] = React.useState<Tag[]>(\n    defaultTags.map((t, i) => ({ id: `${i}-${t}`, text: t })),\n  );\n  const [inputValue, setInputValue] = React.useState(\"\");\n  const [focused, setFocused] = React.useState(false);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  const addTag = (text: string) => {\n    const trimmed = text.trim();\n    if (!trimmed) return;\n    const next = [...tags, { id: `${Date.now()}-${trimmed}`, text: trimmed }];\n    setTags(next);\n    setInputValue(\"\");\n    if (sound) tick();\n    onChange?.(next.map((t) => t.text));\n  };\n\n  const removeTag = (id: string) => {\n    const next = tags.filter((t) => t.id !== id);\n    setTags(next);\n    if (sound) tick();\n    onChange?.(next.map((t) => t.text));\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      addTag(inputValue);\n    } else if (e.key === \"Backspace\" && inputValue === \"\" && tags.length) {\n      e.preventDefault();\n      removeTag(tags[tags.length - 1].id);\n    }\n  };\n\n  return (\n    <>\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n      <motion.div\n        className=\"ti\"\n        initial={{ opacity: 0, y: 8 }}\n        animate={{ opacity: 1, y: 0 }}\n        transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] }}\n        style={{\n          display: \"inline-flex\",\n          flexDirection: \"column\",\n          gap: 6,\n          ...style,\n        }}\n      >\n        {label && (\n          <span\n            style={{\n              fontSize: 11,\n              fontWeight: 500,\n              letterSpacing: \"0.06em\",\n              textTransform: \"uppercase\" as const,\n              color: \"var(--ti-dim)\",\n              userSelect: \"none\" as const,\n              paddingLeft: 4,\n            }}\n          >\n            {label}\n          </span>\n        )}\n\n        {/* glass container */}\n        <div\n          style={{\n            display: \"flex\",\n            flexWrap: \"wrap\",\n            alignItems: \"center\",\n            gap: 6,\n            padding: \"8px 12px\",\n            minHeight: 48,\n            borderRadius: 12,\n            background: \"var(--ti-glass)\",\n            border: `1px solid ${focused ? \"var(--ti-focus)\" : \"var(--ti-border)\"}`,\n            boxShadow: \"var(--ti-shadow)\",\n            backdropFilter: \"blur(24px)\",\n            WebkitBackdropFilter: \"blur(24px)\",\n            transition: \"border-color 0.2s\",\n            cursor: \"text\",\n          }}\n          onClick={() => inputRef.current?.focus()}\n        >\n          <AnimatePresence mode=\"popLayout\">\n            {tags.map((tag) => (\n              <motion.div\n                key={tag.id}\n                layout\n                initial={{ opacity: 0, scale: 0.8 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.8 }}\n                transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  gap: 4,\n                  padding: \"4px 8px 4px 10px\",\n                  borderRadius: 6,\n                  background: \"var(--ti-tag)\",\n                  border: \"1px solid var(--ti-tag-border)\",\n                  fontSize: 12,\n                  fontWeight: 500,\n                  color: \"var(--ti-hi)\",\n                  userSelect: \"none\" as const,\n                }}\n              >\n                {tag.text}\n                <button\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    removeTag(tag.id);\n                  }}\n                  style={{\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    justifyContent: \"center\",\n                    width: 14,\n                    height: 14,\n                    borderRadius: 7,\n                    border: \"none\",\n                    background: \"transparent\",\n                    cursor: \"pointer\",\n                    color: \"var(--ti-x)\",\n                    transition: \"color 0.15s\",\n                    padding: 0,\n                  }}\n                  onMouseEnter={(e) => {\n                    e.currentTarget.style.color = \"var(--ti-x-h)\";\n                  }}\n                  onMouseLeave={(e) => {\n                    e.currentTarget.style.color = \"var(--ti-x)\";\n                  }}\n                >\n                  <svg\n                    width={10}\n                    height={10}\n                    viewBox=\"0 0 16 16\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                  >\n                    <path d=\"M4 4l8 8M12 4l-8 8\" />\n                  </svg>\n                </button>\n              </motion.div>\n            ))}\n          </AnimatePresence>\n\n          <input\n            ref={inputRef}\n            value={inputValue}\n            onChange={(e) => setInputValue(e.target.value)}\n            onKeyDown={handleKeyDown}\n            onFocus={() => setFocused(true)}\n            onBlur={() => setFocused(false)}\n            placeholder={tags.length === 0 ? placeholder : \"\"}\n            style={{\n              flex: 1,\n              minWidth: 80,\n              height: 30,\n              background: \"transparent\",\n              border: \"none\",\n              outline: \"none\",\n              fontSize: 13,\n              color: \"var(--ti-hi)\",\n            }}\n          />\n        </div>\n\n        {hint && (\n          <span\n            style={{\n              fontSize: 11,\n              color: \"var(--ti-dim)\",\n              paddingLeft: 4,\n            }}\n          >\n            {hint}\n          </span>\n        )}\n      </motion.div>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/clean-tag-input.tsx"
    }
  ]
}