{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thread-pull",
  "title": "Thread Pull",
  "description": "A thread strung under a line of text that hangs in a real catenary when you pull it. Length is conserved, so dragging to one side starves that segment and floods the other; the parameter is solved by Newton each frame.",
  "dependencies": [],
  "files": [
    {
      "path": "src/components/ThreadPull/index.js",
      "content": "export { default } from './ThreadPull.jsx'\n",
      "type": "registry:component",
      "target": "components/thread-pull/index.js"
    },
    {
      "path": "src/components/ThreadPull/ThreadPull.jsx",
      "content": "'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport './ThreadPull.css'\n\n/* =========================================================\n   ThreadPull\n   A thread strung under a line of text. Grab it and pull —\n   it hangs in a real catenary, and the letters above lean\n   into the slope beneath them.\n\n   A hanging chain is not a parabola. It is\n\n     y = a·cosh(x/a)\n\n   and the two only agree for small sag; pull the thread far\n   and a parabola is visibly too full through the middle and\n   too slack at the anchors. Since the whole point of the\n   component is the shape, it solves the real one.\n\n   The thread has a fixed total length, which is what makes\n   pulling interesting: length is conserved, so dragging to\n   one side starves that segment and floods the other. For a\n   segment spanning w horizontally, with endpoints v apart\n   vertically and arc length ℓ, the catenary parameter a\n   satisfies\n\n     √(ℓ² − v²) = 2a·sinh(w / 2a)\n\n   which has no closed form, so it is solved by Newton from a\n   small-sag seed. Six iterations is plenty — the function is\n   smooth and monotonic in 1/a, and the seed is close.\n\n   A segment pulled taut (ℓ ≤ √(w²+v²)) has no solution, and\n   that is correct rather than a failure: a straight line is\n   what a chain does at full extension, so it is drawn.\n   ========================================================= */\n\nconst clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n)\n\n/* Solve 2a·sinh(w/2a) = s for a, given s = √(ℓ²−v²).\n   Newton on u = w/(2a), where the equation becomes sinh(u)/u = s/w. */\nfunction catenaryA(w, v, len) {\n  const s2 = len * len - v * v\n  if (s2 <= 0) return null\n  const s = Math.sqrt(s2)\n  if (s <= w) return null            // taut: no sag to solve for\n\n  const target = s / w               // sinh(u)/u, always > 1 here\n  // Small-sag seed: sinh(u)/u ≈ 1 + u²/6  ⇒  u ≈ √(6(target−1))\n  let u = Math.max(1e-4, Math.sqrt(6 * (target - 1)))\n\n  for (let i = 0; i < 6; i++) {\n    const sh = Math.sinh(u)\n    const ch = Math.cosh(u)\n    const f = sh / u - target\n    const df = (ch * u - sh) / (u * u)\n    if (Math.abs(df) < 1e-12) break\n    u = Math.max(1e-6, u - f / df)\n  }\n  return w / (2 * u)\n}\n\n/* Points along one catenary segment between two anchors, with the given arc\n   length. Falls back to a straight line when the segment is taut. */\nfunction segment(x0, y0, x1, y1, len, steps) {\n  const w = x1 - x0\n  const v = y1 - y0\n  const out = []\n  const a = catenaryA(Math.abs(w), v, len)\n\n  if (!a || !isFinite(a)) {\n    for (let i = 0; i <= steps; i++) {\n      const t = i / steps\n      out.push([x0 + w * t, y0 + v * t])\n    }\n    return out\n  }\n\n  /* Standard form y = a·cosh((x−x₀)/a): the horizontal offset of the vertex\n     and the vertical offset both follow from making the curve hit both\n     anchors, which is the pair of equations below solved for x₀ and c. */\n  const aw = Math.abs(w)\n  const xv = aw / 2 - a * Math.asinh(v / (2 * a * Math.sinh(aw / (2 * a))))\n  const c = y0 - a * Math.cosh(-xv / a)\n  const dir = w < 0 ? -1 : 1\n\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps\n    const lx = aw * t\n    out.push([x0 + dir * lx, c + a * Math.cosh((lx - xv) / a)])\n  }\n  return out\n}\n\nconst path = (pts) =>\n  pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join('')\n\nexport default function ThreadPull({\n  text = 'Pull the thread',\n\n  slack = 1.06,       // thread length as a multiple of the span, at rest\n  stiffness = 160,    // pull-back spring on the hand's position\n  damping = 13,\n  lean = 0.9,         // how much the local slope tilts a letter\n  rise = 0.35,        // how much the thread's own sag lifts the letters\n  thickness = 2,\n  steps = 48,         // samples per catenary segment\n  color,\n\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const svgRef = useRef(null)\n  const pathRef = useRef(null)\n\n  const chars = useMemo(() => Array.from(text), [text])\n\n  const live = useRef({})\n  live.current = { slack, stiffness, damping, lean, rise, steps }\n\n  useEffect(() => {\n    const root = rootRef.current\n    const svg = svgRef.current\n    const line = pathRef.current\n    if (!root || !svg || !line) return\n\n    const cells = Array.from(root.querySelectorAll('.thread-pull__char'))\n    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n    let W = 0, H = 0, threadY = 0\n    let hx = 0, hy = 0          // hand, in local coordinates\n    let vx = 0, vy = 0\n    let held = false\n    let raf = 0\n    let last = 0\n\n    const measure = () => {\n      const b = root.getBoundingClientRect()\n      W = b.width\n      H = b.height\n      threadY = H - Math.max(6, thickness * 3)\n      svg.setAttribute('viewBox', `0 0 ${W} ${H}`)\n      if (!held) { hx = W / 2; hy = threadY }\n    }\n\n    /* Length is conserved: whatever the left segment does not use, the right\n       one gets. That is what makes dragging sideways starve one side. */\n    const draw = () => {\n      const { slack: sl, steps: st, lean: lk, rise: rk } = live.current\n      const total = W * sl\n      const fx = clamp(hx, 1, Math.max(2, W - 1))\n\n      // Split the length in proportion to each side's horizontal span.\n      const l1 = total * (fx / Math.max(1, W))\n      const l2 = total - l1\n\n      const a = segment(0, threadY, fx, hy, l1, st)\n      const b = segment(fx, hy, W, threadY, l2, st)\n      const pts = a.concat(b.slice(1))\n      line.setAttribute('d', path(pts))\n\n      // Sample the curve under each letter for its local slope and height.\n      for (let i = 0; i < cells.length; i++) {\n        const el = cells[i]\n        const cx = el.offsetLeft + el.offsetWidth / 2\n        const t = clamp(cx / Math.max(1, W), 0, 1)\n        const k = clamp(Math.round(t * (pts.length - 1)), 1, pts.length - 2)\n        const p0 = pts[k - 1]\n        const p1 = pts[k + 1]\n        const slope = (p1[1] - p0[1]) / Math.max(1e-3, p1[0] - p0[0])\n        const drop = pts[k][1] - threadY\n        el.style.transform =\n          `translateY(${(drop * rk).toFixed(2)}px) rotate(${(Math.atan(slope) * lk).toFixed(4)}rad)`\n      }\n    }\n\n    const frame = (now) => {\n      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60\n      last = now\n      const { stiffness: k, damping: c } = live.current\n\n      if (!held) {\n        // Spring the hand back to the thread's resting midpoint.\n        const ax = k * (W / 2 - hx) - c * vx\n        const ay = k * (threadY - hy) - c * vy\n        vx += ax * dt; vy += ay * dt\n        hx += vx * dt; hy += vy * dt\n      }\n      draw()\n\n      if (!held && Math.hypot(vx, vy) < 1.2 && Math.hypot(W / 2 - hx, threadY - hy) < 0.4) {\n        hx = W / 2; hy = threadY; vx = vy = 0\n        draw()\n        raf = 0; last = 0\n        return\n      }\n      raf = requestAnimationFrame(frame)\n    }\n\n    const kick = () => {\n      if (raf || still) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n\n    const local = (e) => {\n      const b = root.getBoundingClientRect()\n      return [e.clientX - b.left, e.clientY - b.top]\n    }\n\n    const onDown = (e) => {\n      const [x, y] = local(e)\n      // Only grab if the pointer is near the thread itself.\n      if (Math.abs(y - threadY) > 40) return\n      held = true\n      hx = x; hy = y; vx = vy = 0\n      root.setPointerCapture?.(e.pointerId)\n      root.classList.add('is-held')\n      kick()\n    }\n\n    const onMove = (e) => {\n      if (!held) return\n      const [x, y] = local(e)\n      hx = x; hy = y\n      kick()\n    }\n\n    const onUp = (e) => {\n      if (!held) return\n      held = false\n      root.releasePointerCapture?.(e.pointerId)\n      root.classList.remove('is-held')\n      kick()\n    }\n\n    const ro = new ResizeObserver(() => { measure(); draw() })\n    ro.observe(root)\n\n    measure()\n    draw()\n\n    if (!still) {\n      root.addEventListener('pointerdown', onDown)\n      root.addEventListener('pointermove', onMove, { passive: true })\n      window.addEventListener('pointerup', onUp)\n      window.addEventListener('pointercancel', onUp)\n    }\n\n    let dead = false\n    document.fonts?.ready?.then(() => { if (!dead) { measure(); draw() } })\n\n    return () => {\n      dead = true\n      ro.disconnect()\n      root.removeEventListener('pointerdown', onDown)\n      root.removeEventListener('pointermove', onMove)\n      window.removeEventListener('pointerup', onUp)\n      window.removeEventListener('pointercancel', onUp)\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [chars, thickness])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`thread-pull ${className}`.trim()}\n      style={style}\n      role=\"img\"\n      aria-label={text}\n      {...rest}\n    >\n      <span className=\"thread-pull__line\" aria-hidden=\"true\">\n        {chars.map((ch, i) => (\n          <span\n            key={i}\n            className={`thread-pull__char${ch === ' ' ? ' is-space' : ''}`}\n          >\n            {ch === ' ' ? ' ' : ch}\n          </span>\n        ))}\n      </span>\n      <svg ref={svgRef} className=\"thread-pull__svg\" aria-hidden=\"true\" preserveAspectRatio=\"none\">\n        <path\n          ref={pathRef}\n          fill=\"none\"\n          stroke={color || 'currentColor'}\n          strokeWidth={thickness}\n          strokeLinecap=\"round\"\n        />\n      </svg>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/thread-pull/ThreadPull.jsx"
    },
    {
      "path": "src/components/ThreadPull/ThreadPull.css",
      "content": "/* ThreadPull — the SVG sits under the text and never takes the pointer; the\n   root handles the grab, so the whole area is draggable near the thread. */\n\n.thread-pull {\n  position: relative;\n  display: inline-block;\n  padding-bottom: 22px;   /* room for the thread to sag into */\n  cursor: grab;\n  user-select: none;\n  touch-action: none;\n}\n\n.thread-pull.is-held { cursor: grabbing; }\n\n.thread-pull__line {\n  display: block;\n  position: relative;\n  z-index: 1;\n}\n\n.thread-pull__char {\n  display: inline-block;\n  transform-origin: 50% 100%;\n  will-change: transform;\n}\n\n.thread-pull__char.is-space { white-space: pre; }\n\n.thread-pull__svg {\n  position: absolute;\n  inset: 0;\n  z-index: 0;\n  overflow: visible;\n  pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .thread-pull { cursor: default; }\n  .thread-pull__char { will-change: auto; }\n}\n",
      "type": "registry:file",
      "target": "components/thread-pull/ThreadPull.css"
    },
    {
      "path": "src/components/ThreadPull/ThreadPull.d.ts",
      "content": "import type { ComponentPropsWithoutRef } from 'react'\n\nexport interface ThreadPullProps extends ComponentPropsWithoutRef<'div'> {\n  /** Rendered per glyph, and used as the element's accessible name. */\n  text?: string\n  /** Thread length as a multiple of its span. 1 is taut; above 1 it sags. */\n  slack?: number\n  /** Pull-back spring on the hand once released. */\n  stiffness?: number\n  damping?: number\n  /** How much the thread's local slope tilts the letter above it. */\n  lean?: number\n  /** How much the thread's sag pulls the letters down with it. */\n  rise?: number\n  /** Stroke width, px. */\n  thickness?: number\n  /** Samples per catenary segment. */\n  steps?: number\n  /** Stroke colour. Defaults to currentColor. */\n  color?: string\n}\n\ndeclare const ThreadPull: (props: ThreadPullProps) => JSX.Element\nexport default ThreadPull\n",
      "type": "registry:file",
      "target": "components/thread-pull/ThreadPull.d.ts"
    }
  ],
  "type": "registry:component"
}