{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input-tear",
  "title": "Tag Input Tear",
  "description": "Tag input whose chips hang from a perforated edge instead of an x icon: dragging a chip down snaps its dashes into torn stubs top-to-bottom as a 0-1 tear progress crosses each one, and past 80% releasing drops the chip with a 4deg rotate and gravity while siblings close the gap via FLIP; releasing early springs everything back and re-knits the perforation in reverse. Delete/Backspace on a focused chip removes it instantly through a plain 150ms fade, no tear theater, and Ctrl+Z undoes any removal.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/tag-input-tear/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// TearTab — a tag input where every chip hangs from a perforated edge (a\n// column of short SVG dashes, stroke --border) instead of carrying an x\n// icon. At rest the perforation itself reads \"removable.\" Deletion has two\n// paths that both end at the same place (tag gone, siblings closed the gap,\n// action announced, Ctrl+Z reverses it):\n//\n//   - Pointer sugar: drag a chip down. Pointer distance maps 1:1 to a 0-1\n//     tear progress; each dash flips from one intact line to two offset\n//     stubs the instant progress passes its own index/DASH_COUNT threshold,\n//     with a brief 1px translate jitter on the dash that just snapped. On\n//     release, progress >= 0.8 commits the tear — the chip rotates 4deg and\n//     drops (ease-in, gravity-flavored) via a short-lived fixed-position\n//     ghost while the real chip is already gone from flow; progress < 0.8\n//     springs the whole thing back to 0 and the dashes re-knit in reverse,\n//     for free, because \"broken\" is just `progress >= threshold` — no\n//     separate reverse-animation path to keep in sync with forward.\n//   - Keyboard, the only required path: Tab/arrow keys rove focus across\n//     chips (role=option in a role=listbox), Delete/Backspace on a focused\n//     chip removes it via a fast 150ms opacity fade, no tear theater. This\n//     is also where reduced-motion and a duplicate-safety net both land —\n//     see the reducedRef guards below.\n//\n// Sibling reflow uses a hand-rolled FLIP: before every tags-array mutation\n// (add, either removal path, undo) the surviving chips' rects are snapshotted;\n// after React commits the new layout, each chip is offset back to its old\n// position and released on a spring-approximating cubic-bezier (the same\n// honest-approximation call this registry already made in drill-down-spines/\n// slider-range-shear — a real spring integrator wasn't worth it for a one-shot\n// settle). Ctrl+Z / Cmd+Z pops a small undo stack (tag + its removal index)\n// and re-inserts, reusing the same FLIP path. Pure DOM + inline SVG + CSS;\n// no canvas, no dependencies.\n// ---------------------------------------------------------------------------\n\nconst DASH_COUNT = 7;\nconst CHIP_H = 28; // px, fixed — keeps the SVG viewBox in real px, no scaling\nconst TEAR_DISTANCE = 64; // px of downward drag for progress 0 -> 1\nconst RELEASE_AT = 0.8;\nconst FALL_MS = 340;\nconst FADE_MS = 150;\nconst FLIP_MS = 380;\nconst JITTER_MS = 120;\nconst SPRING_K = 260; // s^-2\nconst SPRING_ZETA = 0.5;\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\ninterface UndoEntry {\n  tag: string;\n  index: number;\n}\n\ninterface GhostRect {\n  tag: string;\n  left: number;\n  top: number;\n  width: number;\n}\n\nexport interface TearTabProps {\n  /** initial tag list */\n  defaultTags?: string[];\n  /** placeholder shown only while the list is empty */\n  placeholder?: string;\n  /** accessible name for the chip listbox */\n  label?: string;\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 Perforation({\n  progress,\n  jitterIndex,\n  armed,\n}: {\n  progress: number;\n  jitterIndex: number | null;\n  armed: boolean;\n}) {\n  const stroke = armed ? \"var(--ns-accent)\" : \"var(--border)\";\n  return (\n    <svg\n      aria-hidden\n      width={9}\n      height={CHIP_H}\n      viewBox={`0 0 9 ${CHIP_H}`}\n      className=\"block shrink-0\"\n    >\n      {Array.from({ length: DASH_COUNT }, (_, i) => {\n        const cy = 4 + (i * (CHIP_H - 8)) / (DASH_COUNT - 1);\n        const threshold = (i + 1) / DASH_COUNT;\n        const torn = progress >= threshold;\n        const jittering = jitterIndex === i;\n        return torn ? (\n          <g key={i} className={jittering ? \"tag-input-tear-jitter\" : undefined}>\n            <line x1={1.5} x2={3.6} y1={cy - 1} y2={cy - 1} stroke={stroke} strokeWidth={1.3} strokeLinecap=\"round\" />\n            <line x1={4.4} x2={7.2} y1={cy + 1} y2={cy + 1} stroke={stroke} strokeWidth={1.3} strokeLinecap=\"round\" />\n          </g>\n        ) : (\n          <line key={i} x1={1.5} x2={7.2} y1={cy} y2={cy} stroke={stroke} strokeWidth={1.3} strokeLinecap=\"round\" />\n        );\n      })}\n    </svg>\n  );\n}\n\nexport function TearTab({\n  defaultTags = [],\n  placeholder = \"Add a tag…\",\n  label = \"Tags\",\n  onChange,\n  className = \"\",\n}: TearTabProps) {\n  const [tags, setTagsState] = useState<string[]>(defaultTags);\n  const [draft, setDraft] = useState(\"\");\n  const [fadingIds, setFadingIds] = useState<Set<string>>(new Set());\n  const [ghost, setGhost] = useState<GhostRect | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const [focusedTag, setFocusedTag] = useState<string | null>(null);\n\n  // hot-path drag state lives in refs; dragProgress is mirrored to state\n  // purely so the dragged chip re-renders as the pointer moves\n  const [dragId, setDragId] = useState<string | null>(null);\n  const [dragProgress, setDragProgressState] = useState(0);\n  const [jitterIndex, setJitterIndex] = useState<number | null>(null);\n\n  const tagsRef = useRef(tags);\n  tagsRef.current = tags;\n  const dragIdRef = useRef<string | null>(null);\n  const dragProgressRef = useRef(0);\n  const dragStartYRef = useRef(0);\n  const prevBrokenRef = useRef<boolean[]>(new Array(DASH_COUNT).fill(false));\n  const undoStackRef = useRef<UndoEntry[]>([]);\n  const chipRefs = useRef(new Map<string, HTMLLIElement>());\n  const prevRectsRef = useRef(new Map<string, DOMRect>());\n  const inputRef = useRef<HTMLInputElement>(null);\n  const timersRef = useRef(new Set<ReturnType<typeof setTimeout>>());\n  const rafRef = useRef(0);\n  const jitterTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  // window-level pointermove/up/cancel handlers, kept fresh via refs so a\n  // single mount-time listener never goes stale — see the drag effect below\n  // for why this has to live on window instead of the chip element.\n  const onWindowPointerMoveRef = useRef<(e: PointerEvent) => void>(() => {});\n  const onWindowPointerUpRef = useRef<(e: PointerEvent) => void>(() => {});\n  const onWindowPointerCancelRef = useRef<(e: PointerEvent) => void>(() => {});\n\n  const reducedRef = useRef(\n    typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n  );\n\n  useEffect(() => {\n    const timers = timersRef.current;\n    return () => {\n      timers.forEach(clearTimeout);\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n      if (jitterTimerRef.current) clearTimeout(jitterTimerRef.current);\n    };\n  }, []);\n\n  const track = (ms: number, fn: () => void) => {\n    const t = setTimeout(() => {\n      timersRef.current.delete(t);\n      fn();\n    }, ms);\n    timersRef.current.add(t);\n    return t;\n  };\n\n  const snapshotRects = (excludeTag?: string) => {\n    const map = new Map<string, DOMRect>();\n    chipRefs.current.forEach((el, id) => {\n      if (id === excludeTag) return;\n      map.set(id, el.getBoundingClientRect());\n    });\n    prevRectsRef.current = map;\n  };\n\n  // FLIP: replays position deltas captured just before the last tags mutation\n  useEffect(() => {\n    const prev = prevRectsRef.current;\n    if (prev.size === 0) return;\n    prevRectsRef.current = new Map();\n    if (reducedRef.current) return;\n    chipRefs.current.forEach((el, id) => {\n      const before = prev.get(id);\n      if (!before) return;\n      const after = el.getBoundingClientRect();\n      const dx = before.left - after.left;\n      const dy = before.top - after.top;\n      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;\n      el.style.transition = \"none\";\n      el.style.transform = `translate(${dx}px, ${dy}px)`;\n      void el.offsetHeight; // force reflow before releasing the transition\n      el.style.transition = `transform ${FLIP_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1)`;\n      el.style.transform = \"\";\n      const clear = () => {\n        el.style.transition = \"\";\n      };\n      el.addEventListener(\"transitionend\", clear, { once: true });\n    });\n  }, [tags]);\n\n  const setTags = (next: string[]) => {\n    setTagsState(next);\n    onChange?.(next);\n  };\n\n  const focusNeighborOf = (tag: string) => {\n    const idx = tagsRef.current.indexOf(tag);\n    const neighbor = tagsRef.current[idx + 1] ?? tagsRef.current[idx - 1] ?? null;\n    return () => {\n      if (neighbor) chipRefs.current.get(neighbor)?.focus({ preventScroll: true });\n      else inputRef.current?.focus({ preventScroll: true });\n    };\n  };\n\n  const removeTagNow = (tag: string, excludeFromFlip: boolean) => {\n    const idx = tagsRef.current.indexOf(tag);\n    if (idx === -1) return;\n    snapshotRects(excludeFromFlip ? tag : undefined);\n    const next = tagsRef.current.filter((t) => t !== tag);\n    setTags(next);\n    undoStackRef.current.push({ tag, index: idx });\n    if (undoStackRef.current.length > 20) undoStackRef.current.shift();\n    setAnnounce(\n      `removed ${tag}, ${next.length} tag${next.length === 1 ? \"\" : \"s\"} remain${next.length === 1 ? \"s\" : \"\"}`,\n    );\n  };\n\n  const fastFadeRemove = (tag: string) => {\n    if (fadingIds.has(tag)) return;\n    const focusNeighbor = focusNeighborOf(tag);\n    setFadingIds((prev) => new Set(prev).add(tag));\n    track(reducedRef.current ? 0 : FADE_MS, () => {\n      setFadingIds((prev) => {\n        const n = new Set(prev);\n        n.delete(tag);\n        return n;\n      });\n      removeTagNow(tag, false);\n      requestAnimationFrame(focusNeighbor);\n    });\n  };\n\n  const clearDrag = () => {\n    dragIdRef.current = null;\n    setDragId(null);\n    dragProgressRef.current = 0;\n    setDragProgressState(0);\n    prevBrokenRef.current = new Array(DASH_COUNT).fill(false);\n  };\n\n  const commitDrop = (tag: string) => {\n    const el = chipRefs.current.get(tag);\n    const rect = el?.getBoundingClientRect() ?? null;\n    clearDrag();\n    if (reducedRef.current || !rect) {\n      fastFadeRemove(tag);\n      return;\n    }\n    const focusNeighbor = focusNeighborOf(tag);\n    setGhost({ tag, left: rect.left, top: rect.top, width: rect.width });\n    removeTagNow(tag, true);\n    track(FALL_MS, () => setGhost(null));\n    requestAnimationFrame(focusNeighbor);\n  };\n\n  const springBack = () => {\n    if (reducedRef.current) {\n      clearDrag();\n      return;\n    }\n    if (rafRef.current) cancelAnimationFrame(rafRef.current);\n    let vel = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = Math.min(0.032, Math.max(0, (now - last) / 1000));\n      last = now;\n      const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n      vel += (-SPRING_K * dragProgressRef.current - c * vel) * dt;\n      dragProgressRef.current += vel * dt;\n      if (Math.abs(dragProgressRef.current) < 0.004 && Math.abs(vel) < 0.02) {\n        rafRef.current = 0;\n        clearDrag();\n        return;\n      }\n      applyProgress(clamp(dragProgressRef.current, 0, 1));\n      rafRef.current = requestAnimationFrame(tick);\n    };\n    rafRef.current = requestAnimationFrame(tick);\n  };\n\n  function applyProgress(p: number) {\n    dragProgressRef.current = p;\n    setDragProgressState(p);\n    for (let i = 0; i < DASH_COUNT; i++) {\n      const broken = p >= (i + 1) / DASH_COUNT;\n      if (broken !== prevBrokenRef.current[i]) {\n        prevBrokenRef.current[i] = broken;\n        if (!reducedRef.current) {\n          setJitterIndex(i);\n          if (jitterTimerRef.current) clearTimeout(jitterTimerRef.current);\n          jitterTimerRef.current = setTimeout(() => setJitterIndex(null), JITTER_MS);\n        }\n      }\n    }\n  }\n\n  const onChipPointerDown = (tag: string) => (e: React.PointerEvent<HTMLLIElement>) => {\n    if (e.button !== 0) return;\n    const el = e.currentTarget;\n    el.focus({ preventScroll: true });\n    setFocusedTag(tag);\n    el.setPointerCapture(e.pointerId);\n    dragIdRef.current = tag;\n    setDragId(tag);\n    dragStartYRef.current = e.clientY;\n    prevBrokenRef.current = new Array(DASH_COUNT).fill(false);\n    applyProgress(0);\n    e.preventDefault();\n  };\n\n  // Drag tracking lives on window, not the chip: a chip-scoped pointermove\n  // only fires while the pointer stays over the chip's own box (or, for a\n  // real mouse, while setPointerCapture is honored — which it isn't for the\n  // synthetic pointer events the registry's autoplay preview dispatches).\n  // Once the cursor crosses the chip's bottom edge mid-tear, a chip-scoped\n  // listener stops receiving events and progress sticks at its last value.\n  // Listening on window guarantees delivery regardless of where the pointer\n  // physically is, for both real and synthetic pointers. The handlers are\n  // kept fresh via refs (updated every render, no dependency array) and\n  // wired to window exactly once on mount, so the listener identity never\n  // changes and no event is ever dropped between remove/re-add.\n  useEffect(() => {\n    onWindowPointerMoveRef.current = (e: PointerEvent) => {\n      if (!dragIdRef.current) return;\n      const dy = Math.max(0, e.clientY - dragStartYRef.current);\n      applyProgress(clamp(dy / TEAR_DISTANCE, 0, 1));\n    };\n    onWindowPointerUpRef.current = () => {\n      const tag = dragIdRef.current;\n      if (!tag) return;\n      if (dragProgressRef.current >= RELEASE_AT) commitDrop(tag);\n      else springBack();\n    };\n    onWindowPointerCancelRef.current = () => {\n      if (dragIdRef.current) springBack();\n    };\n  });\n\n  useEffect(() => {\n    const move = (e: PointerEvent) => onWindowPointerMoveRef.current(e);\n    const up = (e: PointerEvent) => onWindowPointerUpRef.current(e);\n    const cancel = (e: PointerEvent) => onWindowPointerCancelRef.current(e);\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", cancel);\n    return () => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", cancel);\n    };\n  }, []);\n\n  const undo = () => {\n    const entry = undoStackRef.current.pop();\n    if (!entry || tagsRef.current.includes(entry.tag)) return;\n    snapshotRects();\n    const next = [...tagsRef.current];\n    next.splice(Math.min(entry.index, next.length), 0, entry.tag);\n    setTags(next);\n    setAnnounce(`restored ${entry.tag}`);\n  };\n\n  const commitDraft = () => {\n    const tag = draft.trim();\n    setDraft(\"\");\n    if (!tag || tagsRef.current.includes(tag)) return;\n    snapshotRects();\n    const next = [...tagsRef.current, tag];\n    setTags(next);\n  };\n\n  const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Enter\" || e.key === \",\") {\n      e.preventDefault();\n      commitDraft();\n    }\n  };\n\n  const chipIndex = (tag: string) => tagsRef.current.indexOf(tag);\n\n  const onChipKeyDown = (tag: string) => (e: React.KeyboardEvent<HTMLLIElement>) => {\n    if (e.key === \"Delete\" || e.key === \"Backspace\") {\n      e.preventDefault();\n      fastFadeRemove(tag);\n      return;\n    }\n    const i = chipIndex(tag);\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n      e.preventDefault();\n      const dir = e.key === \"ArrowLeft\" ? -1 : 1;\n      const ni = clamp(i + dir, 0, tagsRef.current.length - 1);\n      if (dir > 0 && i === tagsRef.current.length - 1) {\n        inputRef.current?.focus();\n        return;\n      }\n      const nextTag = tagsRef.current[ni];\n      setFocusedTag(nextTag);\n      chipRefs.current.get(nextTag)?.focus({ preventScroll: true });\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      const first = tagsRef.current[0];\n      if (first) {\n        setFocusedTag(first);\n        chipRefs.current.get(first)?.focus({ preventScroll: true });\n      }\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      const last = tagsRef.current[tagsRef.current.length - 1];\n      if (last) {\n        setFocusedTag(last);\n        chipRefs.current.get(last)?.focus({ preventScroll: true });\n      }\n    }\n  };\n\n  const onRootKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if ((e.key === \"z\" || e.key === \"Z\") && (e.ctrlKey || e.metaKey) && !e.altKey) {\n      if (undoStackRef.current.length > 0) {\n        e.preventDefault();\n        undo();\n      }\n    }\n  };\n\n  return (\n    <div className={`w-full ${className}`} onKeyDown={onRootKeyDown}>\n      <style>{`\n        @keyframes tag-input-tear-in {\n          from { opacity: 0; transform: translateY(4px); }\n          to { opacity: 1; transform: translateY(0); }\n        }\n        @keyframes tag-input-tear-jitter {\n          0% { transform: translate(0, 0); }\n          30% { transform: translate(0, -1px); }\n          60% { transform: translate(0, 1px); }\n          100% { transform: translate(0, 0); }\n        }\n        @keyframes tag-input-tear-fall {\n          from { transform: translateY(0) rotate(0deg); opacity: 1; }\n          to { transform: translateY(48px) rotate(4deg); opacity: 0; }\n        }\n        .tag-input-tear-chip { animation: tag-input-tear-in 180ms ease-out backwards; }\n        .tag-input-tear-jitter { animation: tag-input-tear-jitter ${JITTER_MS}ms ease-out; }\n        .tag-input-tear-fading { transition: opacity ${FADE_MS}ms ease, transform ${FADE_MS}ms ease; opacity: 0; transform: scale(0.94); }\n        .tag-input-tear-ghost { animation: tag-input-tear-fall ${FALL_MS}ms cubic-bezier(0.55, 0, 1, 0.45) forwards; }\n        @media (prefers-reduced-motion: reduce) {\n          .tag-input-tear-chip, .tag-input-tear-jitter, .tag-input-tear-ghost { animation: none; }\n          .tag-input-tear-fading { transition: none; opacity: 0; }\n        }\n      `}</style>\n\n      <div\n        onClick={(e) => {\n          if (!(e.target as HTMLElement).closest('[role=\"option\"]')) inputRef.current?.focus();\n        }}\n        className=\"w-full cursor-text rounded-md border border-border bg-surface px-3 py-2.5 transition-colors duration-150 focus-within:border-ns-muted\"\n      >\n        <ul\n          role=\"listbox\"\n          aria-label={label}\n          className={`flex flex-wrap items-center gap-x-1.5 gap-y-2 ${tags.length > 0 ? \"mb-2\" : \"\"}`}\n        >\n          {tags.map((tag) => {\n            const dragging = dragId === tag;\n            const progress = dragging ? dragProgress : 0;\n            const armed = dragging && progress >= RELEASE_AT;\n            const isFading = fadingIds.has(tag);\n            const isFocused = focusedTag === tag || (focusedTag === null && chipIndex(tag) === 0);\n            return (\n              <li\n                key={tag}\n                ref={(el) => {\n                  if (el) chipRefs.current.set(tag, el);\n                  else chipRefs.current.delete(tag);\n                }}\n                role=\"option\"\n                tabIndex={isFocused ? 0 : -1}\n                onFocus={() => setFocusedTag(tag)}\n                onPointerDown={onChipPointerDown(tag)}\n                onKeyDown={onChipKeyDown(tag)}\n                data-tear-first={chipIndex(tag) === 0 || undefined}\n                className={[\n                  \"tag-input-tear-chip inline-flex h-7 shrink-0 cursor-grab touch-none select-none items-stretch\",\n                  \"overflow-visible rounded-md border border-l-0 border-border bg-background\",\n                  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\",\n                  isFading ? \"tag-input-tear-fading\" : \"\",\n                ].join(\" \")}\n                style={{\n                  transform: dragging\n                    ? `translateY(${progress * 14}px) rotate(${progress * 2}deg)`\n                    : undefined,\n                  transition: dragging ? \"none\" : undefined,\n                }}\n              >\n                <Perforation progress={progress} jitterIndex={dragging ? jitterIndex : null} armed={armed} />\n                <span\n                  aria-hidden\n                  style={{ width: 4 + progress * 8 }}\n                  className=\"shrink-0\"\n                />\n                <span className=\"flex min-w-0 max-w-[16ch] items-center truncate pr-2.5 text-xs text-foreground\">\n                  {tag}\n                </span>\n              </li>\n            );\n          })}\n        </ul>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          aria-label={placeholder}\n          value={draft}\n          placeholder={placeholder}\n          onChange={(e) => setDraft(e.target.value)}\n          onKeyDown={onInputKeyDown}\n          className=\"w-full min-w-[8ch] bg-transparent py-0.5 text-sm text-foreground outline-none placeholder:text-ns-muted\"\n        />\n      </div>\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n\n      {ghost ? (\n        <div\n          aria-hidden\n          className=\"tag-input-tear-ghost pointer-events-none fixed z-50 flex h-7 items-center overflow-hidden rounded-md border border-border bg-background\"\n          style={{ left: ghost.left, top: ghost.top, width: ghost.width }}\n        >\n          <Perforation progress={1} jitterIndex={null} armed />\n          <span className=\"truncate px-2.5 text-xs text-foreground\">{ghost.tag}</span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/tag-input-tear.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",
      "drag",
      "listbox",
      "micro-interaction"
    ],
    "instruction": "Build a tag/chip input where every chip is attached by a perforated left edge instead of carrying a delete icon. STRUCTURE: a click-to-focus bg-surface field (cursor-text, focus-within border shift to muted) laid out as a wrapping flex row containing a <ul role=listbox aria-label={label}> of chips (className=contents so its <li> children share the outer flex row) followed by the free-text <input>. Each chip <li role=option> is a fixed h-7 (28px) pill: border on top/right/bottom only (border-l-0, the perforation stands in for the left edge), rounded-md, bg-background, holding an inline SVG strip (viewBox in real px so stroke width never scales) of 7 short horizontal dashes stroke var(--border) evenly spaced down the chip, then a spacer whose width grows with drag progress, then the truncated tag text (max 16ch). DRAG-TO-TEAR (pointer sugar, not a required flow): pointerdown on a chip captures the pointer and focuses it; pointermove maps clamped downward-only dy over a 64px throw to a 0-1 tear progress, mirrored through a ref for the hot path. Each dash's broken state is a pure function of progress (progress >= (index+1)/7) so re-render alone flips it from one intact line to two 1px-offset stubs — no separate reverse-animation path needed for re-knitting. The instant a dash's broken state flips (either direction) it gets a brief 1px vertical translate jitter (a short CSS keyframe class applied only to that dash for ~120ms). While held, the chip translates down and rotates up to 2deg proportional to progress (visual tension); once progress crosses 0.8 the dash stroke color swaps border->accent as an 'armed' cue. On release: progress >= 0.8 commits — the real chip is removed from state immediately (so siblings FLIP-close the gap) while a short-lived fixed-position ghost clone (same rect, captured via getBoundingClientRect before removal) plays a 340ms ease-in rotate-to-4deg + translateY(48px) + fade-out fall, then unmounts; progress < 0.8 (or pointercancel) runs an underdamped spring back to progress 0 (rAF integrator, ~260 s^-2 stiffness, zeta 0.5), which re-knits the dashes in reverse via the same pure-function render, then focus returns to a neighbor chip or the input. FLIP: any tags-array mutation (either removal path, add, undo) first snapshots every surviving chip's getBoundingClientRect via a ref map; a useEffect keyed on the tags array then offsets each surviving chip from its old position back to zero on a spring-approximating cubic-bezier(0.34,1.56,0.64,1) over 380ms, skipped entirely under reduced motion (chips just snap). KEYBOARD (the only required path, drag adds nothing to it): chips carry roving tabindex (one tabIndex=0 at a time, defaulting to the first chip) so Tab reaches the list; ArrowLeft/ArrowRight/Home/End move focus among chips (ArrowRight off the last chip moves into the text input); Delete or Backspace on a focused chip removes it via a fast 150ms opacity+scale fade (no rotate, no perforation theater) and moves focus to a neighbor or the input. Enter or comma in the text input commits the draft as a new tag (trimmed, ignored if empty or a duplicate). A visually-hidden aria-live=polite region announces 'removed {tag}, {n} tags remain' on every removal and 'restored {tag}' on undo. Ctrl+Z / Cmd+Z (checked at the root on keydown) pops a small undo stack of {tag, removalIndex} entries and re-inserts the tag at its original index, reusing the FLIP path so its neighbors slide over for it; new/restored chips play a 180ms fade+rise-in entrance. Reduced motion: the jitter, the fall-ghost, the FLIP replay and the release spring are all skipped; a drag that crosses 0.8 and releases routes straight through the same fast-fade removal keyboard deletion uses, so every removal is either instant-with-fade or nothing. INK: tokens only (background, surface, border, muted, foreground, accent for the armed-dash cue and focus rings); pure DOM + inline SVG + CSS, no canvas, no dependencies.",
    "rank": 296
  },
  "type": "registry:ui"
}