{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pluck-string",
  "type": "registry:ui",
  "title": "Pluck String",
  "description": "A divider that catches on the pointer as it crosses, bends with it, then slips free and twangs back on a bouncy spring.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/pluck-string.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * PLUCK STRING STORYBOARD\n *\n *  rest    a straight line through the middle of the box\n *  catch   the pointer crosses the line, either way; the\n *          string catches on it and bends toward it, the bend\n *          riding along under the pointer\n *  slip    the moment the pointer passes `pull` px (less\n *          near the pinned ends) or leaves the box, the\n *          string slips free with the bend it has, flung at\n *          the pointer's speed but never past the limit\n *  twang   a bouncy spring swings it back through the line,\n *          the bend drifting to the middle; the highlight\n *          brightens with the swing and fades as it settles\n *  tap     pressing the line strikes it from rest\n * ───────────────────────────────────────────────────────── */\n\nconst STRING = {\n  span: 1000, // viewBox width: x runs 0 → span, y is in px\n  hold: { stiffness: 8000, damping: 179 }, // while caught: tracks the pointer, no overshoot\n  drift: { stiffness: 60, damping: 15.5 }, // after the slip, the bend eases to the middle\n  strike: 0.7, // a tap kicks the string at this fraction of limit × ω (first swing ≈ 0.55 × limit)\n  step: 1 / 480, // physics substep, s\n  rest: 0.05, // px of swing below which the string is still\n};\n\nconst FLAT = `M0 0H${STRING.span}`;\n\nexport interface PluckStringProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** How far the string stretches with the pointer before it slips free, in px. */\n  pull?: number;\n  /** Spring stiffness of the twang. Higher is a faster, higher-pitched wobble. */\n  stiffness?: number;\n  /** Spring damping of the twang. Lower rings for longer. */\n  damping?: number;\n  /** Stroke width of the string, in px. */\n  strokeWidth?: number;\n  /** Fade the string out toward both ends. */\n  fade?: boolean;\n  /** Classes for the highlight that lights up the string as it swings. Set its color with `text-*`. */\n  highlightClassName?: string;\n}\n\nexport function PluckString({\n  pull = 24,\n  stiffness = 900,\n  damping = 9,\n  strokeWidth = 1,\n  fade = false,\n  highlightClassName,\n  className,\n  ...props\n}: PluckStringProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const stringRef = React.useRef<SVGPathElement>(null);\n  const glowRef = React.useRef<SVGGElement>(null);\n  const glowPathRef = React.useRef<SVGPathElement>(null);\n  const id = React.useId();\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const string = stringRef.current;\n    const glow = glowRef.current;\n    const glowPath = glowPathRef.current;\n    if (!root || !string || !glow || !glowPath) return;\n\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const omega = Math.sqrt(stiffness);\n    const { span, hold, drift } = STRING;\n\n    // x is where the bend sits along the string (viewBox units), y how far it\n    // is pulled off the line (px). `held` is the side the pointer holds it on\n    // (±1, 0 when free) and `side` the side the pointer was last seen on.\n    // py, pt and pv are the pointer's last offset, time and vertical speed.\n    const s = {\n      x: span / 2,\n      vx: 0,\n      y: 0,\n      vy: 0,\n      tx: span / 2,\n      ty: 0,\n      held: 0,\n      side: 0,\n      py: 0,\n      pt: 0,\n      pv: 0,\n    };\n    let frame = 0;\n    let last = 0;\n\n    const draw = () => {\n      // Reduced motion keeps the line straight; only the highlight responds.\n      const y = reducedMotion.matches ? 0 : s.y;\n      const x = Math.min(Math.max(s.x, 0), span);\n      const d =\n        Math.abs(y) < 0.01\n          ? FLAT\n          : `M0 0Q${(x / 2).toFixed(1)} ${y.toFixed(2)} ${x.toFixed(1)} ${y.toFixed(2)}` +\n            `Q${((x + span) / 2).toFixed(1)} ${y.toFixed(2)} ${span} 0`;\n      string.setAttribute(\"d\", d);\n      glowPath.setAttribute(\"d\", d);\n\n      const swing = s.held ? Math.abs(s.y) : Math.hypot(s.y, s.vy / omega);\n      glow.style.opacity = String(Math.min(swing / pull, 1));\n    };\n\n    const integrate = (h: number) => {\n      const ky = s.held ? hold : { stiffness, damping };\n      const kx = s.held ? hold : drift;\n      const tx = s.held ? s.tx : span / 2;\n      const ty = s.held ? s.ty : 0;\n\n      // Semi-implicit Euler: stable for these springs at this step size.\n      s.vy += (ky.stiffness * (ty - s.y) - ky.damping * s.vy) * h;\n      s.y += s.vy * h;\n      s.vx += (kx.stiffness * (tx - s.x) - kx.damping * s.vx) * h;\n      s.x += s.vx * h;\n    };\n\n    const tick = (now: number) => {\n      let dt = Math.min(Math.max((now - last) / 1000, 0), 1 / 20);\n      last = now;\n      while (dt > 0) {\n        const h = Math.min(STRING.step, dt);\n        integrate(h);\n        dt -= h;\n      }\n\n      const settled = s.held\n        ? Math.hypot(s.y - s.ty, s.vy / omega) < STRING.rest &&\n          Math.abs(s.x - s.tx) < 0.5 &&\n          Math.abs(s.vx) < 5\n        : Math.hypot(s.y, s.vy / omega) < STRING.rest;\n\n      if (settled && !s.held) {\n        s.y = s.vy = s.vx = 0;\n        s.x = span / 2;\n      }\n      draw();\n      frame = settled ? 0 : requestAnimationFrame(tick);\n    };\n\n    const wake = () => {\n      if (frame) return;\n      last = performance.now();\n      frame = requestAnimationFrame(tick);\n    };\n\n    const locate = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      const u = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);\n      return {\n        x: u * span,\n        dy: event.clientY - rect.top - rect.height / 2,\n        // Pinned at both ends, the string gives less the closer you get to one.\n        limit: pull * 4 * u * (1 - u),\n      };\n    };\n\n    const track = (dy: number, time: number) => {\n      const dt = (time - s.pt) / 1000;\n      if (dt > 0) s.pv = (dy - s.py) / dt;\n      s.py = dy;\n      s.pt = time;\n    };\n\n    // Lets go of the string where it is, flung at `speed` (px/s). A fast pass\n    // can outrun the bend, so the speed carries the pluck; it's capped so the\n    // swing never goes past `limit`.\n    const release = (limit: number, speed: number) => {\n      const room = omega * Math.sqrt(Math.max(limit * limit - s.y * s.y, 0));\n      s.vy = Math.min(Math.max(speed, -room), room);\n      s.held = 0;\n      wake();\n    };\n\n    // Tracks the pointer and catches the string whenever it crosses the line,\n    // a crossing back included.\n    const follow = (event: PointerEvent) => {\n      const p = locate(event);\n      // First event in the box: start from where the pointer was a frame ago,\n      // so a fast entry that lands past the line still counts as a crossing and\n      // brings its speed. (pointerenter reports no movement, so it's read here.)\n      if (!s.side) {\n        s.py = p.dy - event.movementY;\n        s.pt = event.timeStamp - 1000 / 60;\n        s.side = Math.sign(s.py);\n      }\n      track(p.dy, event.timeStamp);\n\n      const side = Math.sign(p.dy) || s.side;\n      if (s.side && side !== s.side) {\n        // From rest the bend starts right under the pointer; mid-swing it\n        // slides there instead, so the shape never jumps.\n        if (Math.abs(s.y) < 1) {\n          s.x = p.x;\n          s.vx = 0;\n        }\n        s.held = side;\n      }\n      s.side = side;\n      return p;\n    };\n\n    const onMove = (event: PointerEvent) => {\n      const { x, dy, limit } = follow(event);\n      if (!s.held) return;\n      // Past the limit the pointer loses its grip right away.\n      if (Math.abs(dy) > limit) return release(limit, s.pv);\n      s.tx = x;\n      s.ty = dy;\n      wake();\n    };\n\n    const onLeave = (event: PointerEvent) => {\n      // A fast pass can cross the line between its last move in the box and\n      // the exit, so the exit counts too. Then the pointer lets go for good: it\n      // catches the string again only by coming back across the line.\n      const { limit } = follow(event);\n      s.side = 0;\n      if (s.held) release(limit, s.pv);\n    };\n\n    // A cancelled touch may report no position, so just let go where it is.\n    const onCancel = () => {\n      s.side = 0;\n      if (s.held) release(pull, 0);\n    };\n\n    const onDown = (event: PointerEvent) => {\n      const { x, dy, limit } = locate(event);\n      if (s.held) return release(limit, 0);\n      if (Math.abs(s.y) < 1) s.x = x;\n      // A strike: kick the velocity toward the side that was pressed.\n      s.vy += (Math.sign(dy) || 1) * limit * omega * STRING.strike;\n      wake();\n    };\n\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerleave\", onLeave);\n    root.addEventListener(\"pointercancel\", onCancel);\n    root.addEventListener(\"pointerdown\", onDown);\n    return () => {\n      cancelAnimationFrame(frame);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointercancel\", onCancel);\n      root.removeEventListener(\"pointerdown\", onDown);\n      string.setAttribute(\"d\", FLAT);\n      glowPath.setAttribute(\"d\", FLAT);\n      glow.style.opacity = \"0\";\n    };\n  }, [pull, stiffness, damping]);\n\n  const stroke = (gradient: string) => (fade ? `url(#${gradient})` : \"currentColor\");\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"separator\"\n      className={cn(\"relative h-14 w-full text-border\", className)}\n      {...props}\n    >\n      {/* A 1px-tall viewBox centered on the line: x stretches to the width,\n          y stays in px, and the stroke never scales. The bend overflows it. */}\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-1/2 h-px w-full overflow-visible\"\n        viewBox={`0 -0.5 ${STRING.span} 1`}\n        preserveAspectRatio=\"none\"\n        fill=\"none\"\n        strokeWidth={strokeWidth}\n        strokeLinecap=\"round\"\n      >\n        {fade ? <FadeGradient id={`${id}-string`} /> : null}\n        <path ref={stringRef} d={FLAT} stroke={stroke(`${id}-string`)} vectorEffect=\"non-scaling-stroke\" />\n        <g ref={glowRef} className={cn(\"text-foreground\", highlightClassName)} style={{ opacity: 0 }}>\n          {fade ? <FadeGradient id={`${id}-glow`} /> : null}\n          <path\n            ref={glowPathRef}\n            d={FLAT}\n            stroke={stroke(`${id}-glow`)}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        </g>\n      </svg>\n    </div>\n  );\n}\n\n/**\n * Transparent → currentColor → transparent across the string. In user space,\n * because an objectBoundingBox gradient disappears on a perfectly flat line.\n */\nfunction FadeGradient({ id }: { id: string }) {\n  return (\n    <linearGradient id={id} gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1=\"0\" x2={STRING.span} y2=\"0\">\n      <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n      <stop offset=\"0.2\" stopColor=\"currentColor\" />\n      <stop offset=\"0.8\" stopColor=\"currentColor\" />\n      <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity=\"0\" />\n    </linearGradient>\n  );\n}\n"
    }
  ]
}