{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"elastic-string","type":"registry:component","title":"Elastic string","description":"Pull a string and let go to watch it ring back to rest.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/elastic-string.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Tension = { name: string; stiffness: number; damping: number; weight: number };\n\nconst WIDTH = 480;\nconst HEIGHT = 260;\n// Room at each end for the pegs.\nconst INSET = 28;\n// The furthest a string's middle can be pulled from rest, in px. Kept under\n// half the spacing between strings, so neighbours never cross.\nconst MAX_PULL = 40;\n// Grab band around each string: 56px of target for a 2px line.\nconst BAND = 56;\n// Keeps the bend's peak from sliding right up to a peg, where a quadratic\n// would kink instead of curving.\nconst EDGE = 0.18;\n// Space or Enter strikes the string with this speed (px/s): about what a\n// quick 24px pull and release produces.\nconst STRIKE = 900;\n// Eases the bend back to centre while it rings. A real string's fundamental\n// is symmetric, so the lean toward the grab point fades away.\nconst RECENTER = { type: \"spring\", visualDuration: 0.5, bounce: 0 } as const;\n// Reduced motion: settle without ringing.\nconst SETTLE = { type: \"spring\", visualDuration: 0.3, bounce: 0 } as const;\n\n// Tighter strings ring faster and die sooner, like the high strings on a\n// guitar. Damping ratio stays near 0.1, so each rings for about a second and a\n// half: far longer than a UI transition, because the ring is the point.\nconst STRINGS: Tension[] = [\n  { name: \"High\", stiffness: 1600, damping: 8, weight: 1.25 },\n  { name: \"Middle\", stiffness: 1000, damping: 6.5, weight: 1.75 },\n  { name: \"Low\", stiffness: 600, damping: 5, weight: 2.25 },\n];\n\n// Gives freely at first, then stiffens so it never passes MAX_PULL.\nfunction resist(pull: number) {\n  return MAX_PULL * Math.tanh(pull / (MAX_PULL * 1.6));\n}\n\nexport function ElasticString({\n  y,\n  tension,\n  label,\n}: {\n  y: number;\n  tension: Tension;\n  label: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  // Displacement of the bend's peak from rest, and where along the string\n  // the bend leans (0 to 1). The path is rebuilt from both.\n  const pull = useMotionValue(0);\n  const lean = useMotionValue(0.5);\n  const grab = useRef<{ id: number; y: number; from: number } | null>(null);\n\n  const d = useTransform(() => {\n    const length = WIDTH - INSET * 2;\n    const cx = INSET + lean.get() * length;\n    // A quadratic peaks at half its control point's offset.\n    const cy = y + pull.get() * 2;\n    return `M ${INSET} ${y} Q ${cx} ${cy} ${WIDTH - INSET} ${y}`;\n  });\n\n  useEffect(\n    () => () => {\n      pull.stop();\n      lean.stop();\n    },\n    [pull, lean],\n  );\n\n  const ring = (velocity: number) => {\n    if (reduce) {\n      animate(pull, 0, SETTLE);\n      animate(lean, 0.5, SETTLE);\n      return;\n    }\n    // Hands the release speed to the spring, so a flick rings harder than\n    // a slow let-go from the same height.\n    animate(pull, 0, {\n      type: \"spring\",\n      stiffness: tension.stiffness,\n      damping: tension.damping,\n      velocity,\n      restDelta: 0.05,\n    });\n    animate(lean, 0.5, RECENTER);\n  };\n\n  const along = (e: React.PointerEvent<SVGElement>) => {\n    const box = e.currentTarget.ownerSVGElement!.getBoundingClientRect();\n    const sx = WIDTH / box.width;\n    const x = (e.clientX - box.left) * sx;\n    const t = (x - INSET) / (WIDTH - INSET * 2);\n    return { t: Math.min(Math.max(t, EDGE), 1 - EDGE), scale: HEIGHT / box.height };\n  };\n\n  return (\n    <g\n      role=\"button\"\n      tabIndex={0}\n      aria-label={label}\n      aria-roledescription=\"string\"\n      className=\"group cursor-grab touch-none outline-hidden active:cursor-grabbing\"\n      onPointerDown={(e) => {\n        // A second finger would yank the string to a new spot.\n        if (e.button !== 0 || grab.current) return;\n        e.currentTarget.setPointerCapture(e.pointerId);\n        // Catches it mid-ring from wherever it visibly is.\n        pull.stop();\n        lean.stop();\n        const { t, scale } = along(e);\n        grab.current = { id: e.pointerId, y: e.clientY * scale, from: pull.get() };\n        lean.set(t);\n      }}\n      onPointerMove={(e) => {\n        const g = grab.current;\n        if (!g || g.id !== e.pointerId) return;\n        const { t, scale } = along(e);\n        lean.set(t);\n        // Respects where it was grabbed: the string keeps its offset from\n        // the pointer rather than snapping its peak under it.\n        pull.set(resist(g.from + e.clientY * scale - g.y));\n      }}\n      onPointerUp={(e) => {\n        if (grab.current?.id !== e.pointerId) return;\n        grab.current = null;\n        ring(pull.getVelocity());\n      }}\n      onPointerCancel={(e) => {\n        if (grab.current?.id !== e.pointerId) return;\n        grab.current = null;\n        ring(0);\n      }}\n      onKeyDown={(e) => {\n        if (e.key !== \" \" && e.key !== \"Enter\") return;\n        e.preventDefault();\n        if (e.repeat) return;\n        if (reduce) {\n          // A small nudge that settles, so the press still shows.\n          pull.jump(6);\n          ring(0);\n          return;\n        }\n        lean.jump(0.5);\n        // Strikes from where it is, so repeated plucks add energy.\n        ring(pull.getVelocity() - STRIKE);\n      }}\n    >\n      {/* Transparent, but painted, so it takes the pointer across the band. */}\n      <rect\n        x={INSET}\n        y={y - BAND / 2}\n        width={WIDTH - INSET * 2}\n        height={BAND}\n        fill=\"transparent\"\n      />\n      <rect\n        x={INSET - 12}\n        y={y - BAND / 2 + 4}\n        width={WIDTH - INSET * 2 + 24}\n        height={BAND - 8}\n        rx={12}\n        fill=\"none\"\n        strokeWidth={2}\n        className=\"stroke-foreground opacity-0 group-focus-visible:opacity-100\"\n      />\n      <motion.path\n        d={d}\n        fill=\"none\"\n        strokeWidth={tension.weight}\n        strokeLinecap=\"round\"\n        className=\"stroke-muted-foreground transition-[stroke] duration-150 ease-out group-hover:stroke-foreground group-active:stroke-foreground group-focus-visible:stroke-foreground\"\n      />\n      {[INSET, WIDTH - INSET].map((x) => (\n        <circle key={x} cx={x} cy={y} r={3.5} className=\"fill-foreground\" />\n      ))}\n    </g>\n  );\n}\n\nexport function ElasticStrings({\n  strings = STRINGS,\n  className,\n}: {\n  strings?: Tension[];\n  className?: string;\n}) {\n  const gap = HEIGHT / (strings.length + 1);\n  return (\n    <svg\n      viewBox={`0 0 ${WIDTH} ${HEIGHT}`}\n      role=\"group\"\n      aria-label=\"Strings. Drag one and let go, or focus it and press Space.\"\n      className={cn(\n        \"w-[min(480px,100%)] rounded-2xl bg-muted shadow-raised select-none\",\n        className,\n      )}\n    >\n      {strings.map((tension, i) => (\n        <ElasticString\n          key={tension.name}\n          y={gap * (i + 1)}\n          tension={tension}\n          label={`Pluck ${tension.name.toLowerCase()} string`}\n        />\n      ))}\n    </svg>\n  );\n}\n\nexport default function ElasticStringDemo() {\n  return <ElasticStrings />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/elastic-string"}