{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input-cord",
  "title": "Tag Input Cord",
  "description": "Tag input as beads threaded onto a cord: committing a tag cinches a bead into place with one overshoot squeeze while its knot draws on; Backspace on an empty input unravels the last bead with a drop-and-twist. Duplicate commits shudder the existing bead instead of re-threading it.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/tag-input-cord/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// Tag input as beads cinched onto a cord: committed tags thread onto a wire\n// of short cord segments, each arriving with a cinch — a slide in from the\n// input with one overshoot squeeze — while its knot draws on. Backspace on an\n// empty input unravels the last bead (a drop-and-twist exit) before it leaves\n// the cord. Duplicates don't re-thread; the existing bead shudders instead.\n// Pure DOM+SVG+CSS keyframes; the only JS timers park exiting beads for the\n// length of their unravel animation and are cleared on unmount.\n\nexport interface CinchBeadProps {\n  /** initial tag list */\n  defaultTags?: string[];\n  /** placeholder shown only while the list is empty */\n  placeholder?: string;\n  /** accessible name for the text input */\n  label?: string;\n  /** hard cap on tag count. No default — unlimited. */\n  maxTags?: number;\n  /** called with the full tag list after an add or remove */\n  onChange?: (tags: string[]) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nfunction Knot() {\n  return (\n    <svg aria-hidden width={10} height={10} viewBox=\"0 0 10 10\" className=\"shrink-0\">\n      <circle\n        cx={5}\n        cy={5}\n        r={3.2}\n        fill=\"none\"\n        stroke=\"var(--ns-muted)\"\n        strokeWidth={1.5}\n        pathLength={1}\n        className=\"cinch-knot\"\n      />\n    </svg>\n  );\n}\n\nfunction Cord({ className = \"\" }: { className?: string }) {\n  return <span aria-hidden className={[\"h-px shrink-0 bg-border\", className].join(\" \")} />;\n}\n\nexport function CinchBead({\n  defaultTags = [],\n  placeholder = \"Add a tag…\",\n  label = \"Tags\",\n  maxTags = Infinity,\n  onChange,\n  className = \"\",\n}: CinchBeadProps) {\n  const [tags, setTags] = useState<string[]>(defaultTags);\n  const [leaving, setLeaving] = useState<string[]>([]);\n  const [shaking, setShaking] = useState<string | null>(null);\n  const [draft, setDraft] = useState(\"\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const timersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());\n  const reduced = useRef(\n    typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n  ).current;\n\n  useEffect(() => {\n    const timers = timersRef.current;\n    return () => timers.forEach(clearTimeout);\n  }, []);\n\n  const park = (fn: () => void, ms: number) => {\n    if (reduced) return fn();\n    const t = setTimeout(() => {\n      timersRef.current.delete(t);\n      fn();\n    }, ms);\n    timersRef.current.add(t);\n  };\n\n  const commit = (raw: string) => {\n    const tag = raw.trim();\n    if (!tag) return;\n    if (tags.includes(tag)) {\n      // already threaded — shudder the existing bead instead\n      setShaking(tag);\n      park(() => setShaking(null), 300);\n      setDraft(\"\");\n      return;\n    }\n    if (tags.length >= maxTags) return;\n    const next = [...tags, tag];\n    setTags(next);\n    setDraft(\"\");\n    onChange?.(next);\n  };\n\n  const unravel = (tag: string) => {\n    if (leaving.includes(tag)) return;\n    setLeaving((l) => [...l, tag]);\n    const drop = () => {\n      setLeaving((l) => l.filter((t) => t !== tag));\n      setTags((prev) => {\n        const next = prev.filter((t) => t !== tag);\n        onChange?.(next);\n        return next;\n      });\n    };\n    reduced ? drop() : park(drop, 240);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\" || e.key === \",\") {\n      e.preventDefault();\n      commit(draft);\n    } else if (e.key === \"Backspace\" && draft === \"\" && tags.length > 0) {\n      const last = [...tags].reverse().find((t) => !leaving.includes(t));\n      if (last) unravel(last);\n    }\n  };\n\n  return (\n    <div\n      onClick={() => inputRef.current?.focus()}\n      className={[\n        \"flex w-full cursor-text flex-wrap items-center rounded-md border border-border bg-surface px-3 py-2.5\",\n        \"transition-colors duration-150 focus-within:border-ns-muted\",\n        className,\n      ].join(\" \")}\n    >\n      <style>{`\n        @keyframes cinch-in {\n          0% { opacity: 0; transform: translateX(18px) scaleX(1.1); }\n          62% { opacity: 1; transform: translateX(-2px) scaleX(0.97); }\n          100% { opacity: 1; transform: translateX(0) scaleX(1); }\n        }\n        @keyframes cinch-knot-draw {\n          from { stroke-dashoffset: 1; }\n          to { stroke-dashoffset: 0; }\n        }\n        @keyframes cinch-unravel {\n          to { opacity: 0; transform: translateY(12px) rotate(8deg); }\n        }\n        @keyframes cinch-shudder {\n          0%, 100% { transform: translateX(0); }\n          25% { transform: translateX(-3px); }\n          55% { transform: translateX(2.5px); }\n          80% { transform: translateX(-1.5px); }\n        }\n        .tag-input-cord-chip { animation: cinch-in 280ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }\n        .tag-input-cord-chip .cinch-knot {\n          stroke-dasharray: 1;\n          animation: cinch-knot-draw 320ms 120ms ease-out backwards;\n        }\n        .tag-input-cord-chip[data-leaving=\"true\"] {\n          animation: cinch-unravel 240ms cubic-bezier(0.55, 0, 0.55, 0.2) forwards;\n        }\n        .tag-input-cord-chip[data-shaking=\"true\"] { animation: cinch-shudder 280ms ease-in-out; }\n        @media (prefers-reduced-motion: reduce) {\n          .tag-input-cord-chip, .tag-input-cord-chip .cinch-knot,\n          .tag-input-cord-chip[data-leaving=\"true\"],\n          .tag-input-cord-chip[data-shaking=\"true\"] { animation: none; }\n        }\n      `}</style>\n      <Cord className=\"w-2\" />\n      {tags.map((tag) => (\n        <span key={tag} className=\"flex items-center\">\n          <span\n            data-leaving={leaving.includes(tag) || undefined}\n            data-shaking={shaking === tag || undefined}\n            className=\"tag-input-cord-chip flex items-center gap-1.5 rounded-full border border-border bg-background py-1 pl-2 pr-1\"\n          >\n            <Knot />\n            <span className=\"max-w-[16ch] truncate text-xs text-foreground\">{tag}</span>\n            <button\n              type=\"button\"\n              aria-label={`Remove ${tag}`}\n              onClick={(e) => {\n                e.stopPropagation();\n                unravel(tag);\n              }}\n              className=\"grid size-4 cursor-pointer place-items-center rounded-full text-ns-muted transition-colors duration-150 hover:bg-border hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ns-accent\"\n            >\n              <svg aria-hidden width={8} height={8} viewBox=\"0 0 8 8\">\n                <path\n                  d=\"M 1.5 1.5 L 6.5 6.5 M 6.5 1.5 L 1.5 6.5\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1.4}\n                  strokeLinecap=\"round\"\n                />\n              </svg>\n            </button>\n          </span>\n          <Cord className=\"w-3\" />\n        </span>\n      ))}\n      <input\n        ref={inputRef}\n        type=\"text\"\n        aria-label={label}\n        value={draft}\n        placeholder={tags.length === 0 ? placeholder : \"\"}\n        onChange={(e) => setDraft(e.target.value)}\n        onKeyDown={onKeyDown}\n        className=\"ml-1.5 min-w-[8ch] flex-1 bg-transparent py-0.5 text-sm text-foreground outline-none placeholder:text-ns-muted\"\n      />\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {tags.length} tags\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/tag-input-cord.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "tag-input",
      "chips",
      "input",
      "form",
      "svg",
      "micro-interaction"
    ],
    "instruction": "Build a tag input whose chips read as beads on a wire. STRUCTURE: a click-to-focus bg-surface field (cursor-text, focus-within border shift to muted) laid out as a wrapping flex row: a short cord stub (1px-tall bg-border span), then per tag a pill chip followed by a 12px cord segment, then the flex-1 text input — the h-px segments between pills are what makes the row read as one threaded cord, and they wrap naturally with the chips. Each chip is a rounded-full bg-background bordered pill holding a 10px SVG knot (circle, stroke var(--ns-muted), pathLength 1), the truncated tag text, and a 16px remove button (inline SVG x, aria-label 'Remove {tag}', hover bg-border, focus-visible accent outline). MOTION, all CSS keyframes scoped in a <style> tag: entry 'cinch-in' slides the chip from translateX(18px) scaleX(1.1) through a -2px/0.97 overshoot at 62% into rest (280ms ease-out-back cubic-bezier(0.22,1,0.36,1), fill backwards) while the knot circle draws on via stroke-dashoffset 1->0 delayed 120ms; exit 'cinch-unravel' drops translateY(12px) rotate(8deg) to opacity 0 over 240ms ease-in (fill forwards) — the tag is parked in a 'leaving' list and removed by a setTimeout matching the animation, all timers tracked in a Set and cleared on unmount; duplicate commit fires 'cinch-shudder', a +-3px decaying x-shake on the EXISTING bead, instead of adding. INTERACTION: Enter or comma commits (preventDefault so the comma never lands in the value), trims, ignores empty; Backspace on an empty input unravels the newest non-leaving bead; each remove button unravels its own. onChange fires with the new tag array on every add/remove; a visually-hidden aria-live span announces the count. maxTags caps additions silently. Reduced motion: a media query kills all four animations and the leaving-park executes synchronously. INK: tokens only (background, surface, border, muted, foreground, accent for focus rings); no canvas, no observers."
  },
  "type": "registry:ui"
}