{ "$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(\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(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) => {\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