{
  "name": "plucked-text",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Plucked Text\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PluckedTextProps {\n  /** The line of text that sits on the string. Keep it to one line. */\n  text?: string;\n  className?: string;\n  /** How taut the string is. Higher rings faster and settles sooner. */\n  tension?: number;\n  /** Strum the string when the cursor sweeps across it, not only on drag. */\n  strum?: boolean;\n}\n\n// The string is simulated as a sum of its standing waves (modes), each one a\n// damped oscillator solved exactly, so it can't blow up at any frame rate.\nconst MAX_MODES = 24;\n// No mode may vibrate faster than this many times a second; above it, a\n// 60 Hz screen would alias it into jitter.\nconst MAX_MODE_HZ = 22;\n// Wave speed along the string, in px per second, at tension 1. A real\n// string keeps its wave speed, so a longer one rings lower.\nconst WAVE_SPEED = 1500;\n// Damping: every mode loses some energy, high ones much faster, so a pluck\n// starts sharp and rounds off into a slow sway, as a real string does.\nconst DAMP_BASE = 0.9;\nconst DAMP_HIGH = 0.012;\n// Sample spacing along the string for the drawn path, in px.\nconst STEP = 6;\n// Displacements flatten out past this fraction of the font size, so a\n// hard pull bends the line without throwing letters off it.\nconst MAX_SWING = 0.6;\n// How much of a sweep's vertical speed reaches the string.\nconst STRUM_GAIN = 0.55;\nconst MAX_STRUM_SPEED = 2400;\n// Below this energy the string is at rest and the loop stops.\nconst REST_ENERGY = 0.02;\n// Letters tilt with the string, a little less than its full slope.\nconst TILT = 0.85;\n\ninterface Mode {\n  q: number;\n  v: number;\n  omega: number;\n  gamma: number;\n  damped: number;\n}\n\n/** Advance one damped oscillator by `dt` seconds, exactly. */\nfunction stepMode(mode: Mode, dt: number) {\n  const { q, v, omega, gamma, damped } = mode;\n  const decay = Math.exp(-gamma * dt);\n  const c = Math.cos(damped * dt);\n  const s = Math.sin(damped * dt);\n  mode.q = decay * (q * c + ((v + gamma * q) / damped) * s);\n  mode.v = decay * (v * c - ((omega * omega * q + gamma * v) / damped) * s);\n}\n\n/**\n * The string pulled into a V with its apex at `apex` and height `height`,\n * written as standing waves. Cutting the series short rounds the corner.\n */\nfunction triangleModes(\n  modes: Mode[],\n  length: number,\n  apex: number,\n  height: number\n) {\n  const d = Math.min(Math.max(apex, 1), length - 1);\n  modes.forEach((mode, index) => {\n    const n = index + 1;\n    mode.q =\n      ((2 * height * length * length) /\n        (n * n * Math.PI * Math.PI * d * (length - d))) *\n      Math.sin((n * Math.PI * d) / length);\n    mode.v = 0;\n  });\n}\n\n/** Rubber band: grows like `x` near zero and never past `max`. */\nfunction soften(x: number, max: number) {\n  return max * Math.tanh(x / max);\n}\n\nexport default function PluckedText({\n  text = \"Good vibrations\",\n  className,\n  tension = 1,\n  strum = true,\n}: PluckedTextProps) {\n  const stageRef = useRef<HTMLDivElement>(null);\n  const lettersRef = useRef<HTMLSpanElement>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const pathRef = useRef<SVGPathElement>(null);\n  const ghostRef = useRef<SVGPathElement>(null);\n\n  useEffect(() => {\n    const stage = stageRef.current;\n    const lettersEl = lettersRef.current;\n    const svg = svgRef.current;\n    const path = pathRef.current;\n    const ghost = ghostRef.current;\n    if (!(stage && lettersEl && svg && path && ghost)) {\n      return;\n    }\n\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const letters = Array.from(lettersEl.children).slice(\n      0,\n      -1\n    ) as HTMLElement[];\n\n    let length = 0;\n    let swing = 0;\n    let modes: Mode[] = [];\n    // Precomputed sin and cos of every mode at every sample and letter.\n    let pathBasis: Float64Array[] = [];\n    let letterBasis: { sin: Float64Array; cos: Float64Array }[] = [];\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let lastPath = \"\";\n\n    // Pointer state: a grab holds the string in a V; otherwise the last\n    // position is kept to notice the cursor sweeping across the line.\n    let grab: { id: number; x: number; y: number } | null = null;\n    let release: { from: Float64Array; start: number } | null = null;\n    let prev: { x: number; y: number; t: number } | null = null;\n\n    const measure = () => {\n      // The string sits on the text's baseline, found with an empty\n      // inline-block, whose bottom edge rests on it.\n      const probe = lettersEl.lastElementChild as HTMLElement;\n      stage.style.setProperty(\n        \"--pluck-baseline\",\n        `${lettersEl.offsetTop + probe.offsetTop + probe.offsetHeight}px`\n      );\n      const svgBox = svg.getBoundingClientRect();\n      const lettersLeft = lettersEl.getBoundingClientRect().left - svgBox.left;\n      length = svgBox.width;\n      swing = Number.parseFloat(getComputedStyle(stage).fontSize) * MAX_SWING;\n      const f0 = (WAVE_SPEED * tension) / (2 * length);\n      const count = Math.max(\n        4,\n        Math.min(MAX_MODES, Math.floor(MAX_MODE_HZ / f0))\n      );\n      const old = modes;\n      modes = Array.from({ length: count }, (_, index) => {\n        const n = index + 1;\n        const omega = 2 * Math.PI * f0 * n;\n        const gamma = (DAMP_BASE + DAMP_HIGH * n * n) * Math.sqrt(tension);\n        return {\n          q: old[index]?.q ?? 0,\n          v: old[index]?.v ?? 0,\n          omega,\n          gamma,\n          damped: Math.sqrt(Math.max(omega * omega - gamma * gamma, 1)),\n        };\n      });\n      const samples = Math.ceil(length / STEP) + 1;\n      pathBasis = Array.from({ length: samples }, (_, i) => {\n        const x = Math.min(i * STEP, length);\n        return Float64Array.from(modes, (_m, index) =>\n          Math.sin(((index + 1) * Math.PI * x) / length)\n        );\n      });\n      letterBasis = letters.map((letter) => {\n        // Offsets ignore the transform the loop applies.\n        const x = lettersLeft + letter.offsetLeft + letter.offsetWidth / 2;\n        const k = (index: number) => ((index + 1) * Math.PI) / length;\n        return {\n          sin: Float64Array.from(modes, (_m, index) => Math.sin(k(index) * x)),\n          cos: Float64Array.from(\n            modes,\n            (_m, index) => k(index) * Math.cos(k(index) * x)\n          ),\n        };\n      });\n    };\n\n    /** Displacement of the string where `basis` was sampled. */\n    const sum = (basis: Float64Array) => {\n      let y = 0;\n      for (let i = 0; i < modes.length; i++) {\n        y += modes[i].q * basis[i];\n      }\n      return y;\n    };\n\n    const draw = () => {\n      let d = \"\";\n      for (let i = 0; i < pathBasis.length; i++) {\n        const x = Math.min(i * STEP, length);\n        const y = soften(sum(pathBasis[i]), swing);\n        d += `${i === 0 ? \"M\" : \"L\"}${x.toFixed(1)} ${y.toFixed(2)}`;\n      }\n      // The previous frame stays behind faintly: a vibrating string blurs.\n      ghost.setAttribute(\"d\", lastPath || d);\n      path.setAttribute(\"d\", d);\n      lastPath = d;\n      for (let i = 0; i < letters.length; i++) {\n        const { sin, cos } = letterBasis[i];\n        const raw = sum(sin);\n        const y = soften(raw, swing);\n        // Slope of the softened curve, so tilt eases off with the swing.\n        const t = Math.tanh(raw / swing);\n        const slope = sum(cos) * (1 - t * t);\n        const angle = Math.atan(slope) * TILT;\n        letters[i].style.transform =\n          `translate3d(0, ${y.toFixed(2)}px, 0) rotate(${angle.toFixed(4)}rad)`;\n      }\n    };\n\n    const energy = () =>\n      modes.reduce((e, m) => e + m.v * m.v + m.omega * m.omega * m.q * m.q, 0);\n\n    const settle = () => {\n      for (const mode of modes) {\n        mode.q = 0;\n        mode.v = 0;\n      }\n    };\n\n    /** Reduced motion: no ringing, the line just eases back flat. */\n    const easeBack = (now: number) => {\n      if (!release) {\n        return;\n      }\n      const t = Math.min((now - release.start) / 260, 1);\n      const left = (1 - t) ** 3;\n      const from = release.from;\n      modes.forEach((mode, index) => {\n        mode.q = from[index] * left;\n      });\n      if (t >= 1) {\n        release = null;\n        settle();\n      }\n    };\n\n    const advance = (now: number, dt: number) => {\n      if (release) {\n        easeBack(now);\n      } else if (!grab) {\n        for (const mode of modes) {\n          stepMode(mode, dt);\n        }\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!visible) {\n        return;\n      }\n      const dt = last ? Math.min((now - last) / 1000, 1 / 30) : 1 / 60;\n      last = now;\n      advance(now, dt);\n      draw();\n      if (grab || release || energy() > REST_ENERGY) {\n        raf = requestAnimationFrame(frame);\n        return;\n      }\n      settle();\n      draw();\n      ghost.setAttribute(\"d\", path.getAttribute(\"d\") ?? \"\");\n    };\n\n    const start = () => {\n      if (!raf && visible) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    /** Pointer position along the string and above or below it. */\n    const local = (event: PointerEvent) => {\n      const r = svg.getBoundingClientRect();\n      return { x: event.clientX - r.left, y: event.clientY - r.top };\n    };\n\n    const lineAt = (x: number) => {\n      let y = 0;\n      for (let i = 0; i < modes.length; i++) {\n        y += modes[i].q * Math.sin(((i + 1) * Math.PI * x) / length);\n      }\n      return soften(y, swing);\n    };\n\n    const onDown = (event: PointerEvent) => {\n      if (grab || event.button !== 0) {\n        return;\n      }\n      const point = local(event);\n      if (point.x < 0 || point.x > length) {\n        return;\n      }\n      event.preventDefault();\n      stage.setPointerCapture(event.pointerId);\n      release = null;\n      grab = { id: event.pointerId, x: point.x, y: point.y };\n      stage.dataset.grabbing = \"\";\n      triangleModes(modes, length, point.x, lineAt(point.x));\n      start();\n    };\n\n    const onMove = (event: PointerEvent) => {\n      const point = local(event);\n      if (grab && event.pointerId === grab.id) {\n        // Follow the hand along the string, and up or down with a pull\n        // that stiffens the further it goes.\n        grab.x = Math.min(Math.max(point.x, 0), length);\n        grab.y = soften(point.y, swing * 1.6);\n        triangleModes(modes, length, grab.x, grab.y);\n        start();\n        return;\n      }\n      if (!(strum && event.pointerType === \"mouse\") || reduceMotion) {\n        prev = null;\n        return;\n      }\n      const now = event.timeStamp;\n      if (prev && point.x >= 0 && point.x <= length) {\n        // Did the cursor cross the line since the last move? Then it\n        // strums it there, as hard as it was moving.\n        const before = prev.y - lineAt(prev.x);\n        const after = point.y - lineAt(point.x);\n        if (before * after < 0) {\n          const dt = Math.max(now - prev.t, 8) / 1000;\n          const speed = Math.max(\n            -MAX_STRUM_SPEED,\n            Math.min(MAX_STRUM_SPEED, (point.y - prev.y) / dt)\n          );\n          const where =\n            prev.x + (point.x - prev.x) * (before / (before - after));\n          modes.forEach((mode, index) => {\n            const n = index + 1;\n            mode.v +=\n              ((STRUM_GAIN * speed * 80) / length) *\n              Math.sin((n * Math.PI * where) / length) *\n              Math.exp(-((n / 14) ** 2));\n          });\n          start();\n        }\n      }\n      prev = { x: point.x, y: point.y, t: now };\n    };\n\n    const onUp = (event: PointerEvent) => {\n      if (!grab || event.pointerId !== grab.id) {\n        return;\n      }\n      grab = null;\n      delete stage.dataset.grabbing;\n      if (reduceMotion) {\n        release = {\n          from: Float64Array.from(modes, (mode) => mode.q),\n          start: performance.now(),\n        };\n      }\n      start();\n    };\n\n    const onLeave = () => {\n      prev = null;\n    };\n\n    measure();\n    draw();\n\n    const resizeObserver = new ResizeObserver(() => {\n      measure();\n      draw();\n    });\n    resizeObserver.observe(stage);\n    // Letter widths change when a web font arrives.\n    document.fonts?.ready.then(() => {\n      measure();\n      draw();\n    });\n\n    // One soft strum the first time the line scrolls into view, so it\n    // shows it can move.\n    let greeted = reduceMotion;\n    const intersection = new IntersectionObserver(\n      ([entry]) => {\n        visible =\n          entry.isIntersecting && document.visibilityState === \"visible\";\n        if (!visible) {\n          return;\n        }\n        if (!greeted && entry.intersectionRatio > 0.6) {\n          greeted = true;\n          triangleModes(modes, length, length * 0.3, -swing * 0.5);\n        }\n        start();\n      },\n      { threshold: [0, 0.6] }\n    );\n    intersection.observe(stage);\n    const onVisibility = () => {\n      visible = document.visibilityState === \"visible\";\n      if (visible) {\n        start();\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    stage.addEventListener(\"pointerdown\", onDown);\n    stage.addEventListener(\"pointermove\", onMove);\n    stage.addEventListener(\"pointerup\", onUp);\n    stage.addEventListener(\"pointercancel\", onUp);\n    stage.addEventListener(\"pointerleave\", onLeave);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      resizeObserver.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      stage.removeEventListener(\"pointerdown\", onDown);\n      stage.removeEventListener(\"pointermove\", onMove);\n      stage.removeEventListener(\"pointerup\", onUp);\n      stage.removeEventListener(\"pointercancel\", onUp);\n      stage.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [tension, strum]);\n\n  return (\n    <div className={cn(\"@container flex w-full justify-center\", className)}>\n      <div\n        className=\"group/pluck relative cursor-grab touch-none select-none px-[0.5em] py-[0.7em] font-semibold text-[clamp(2rem,11cqi,6rem)] text-zinc-900 leading-none tracking-tight data-[grabbing]:cursor-grabbing dark:text-zinc-100\"\n        ref={stageRef}\n      >\n        <span className=\"sr-only\">{text}</span>\n        <span\n          aria-hidden=\"true\"\n          className=\"relative block whitespace-pre\"\n          ref={lettersRef}\n        >\n          {Array.from(text).map((char, index) => (\n            <span\n              className=\"inline-block origin-[50%_100%] will-change-transform\"\n              // biome-ignore lint/suspicious/noArrayIndexKey: letters repeat; their position is their identity.\n              key={index}\n            >\n              {char}\n            </span>\n          ))}\n          <span className=\"inline-block h-0 w-0\" />\n        </span>\n        {/* The string runs along the baseline, pinned at both ends. */}\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-[0.25em] h-px w-[calc(100%-0.5em)] overflow-visible text-zinc-400 dark:text-zinc-600\"\n          ref={svgRef}\n          style={{ top: \"var(--pluck-baseline, 1.56em)\" }}\n        >\n          <path\n            className=\"opacity-30\"\n            fill=\"none\"\n            ref={ghostRef}\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeWidth={1.5}\n          />\n          <path\n            fill=\"none\"\n            ref={pathRef}\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeWidth={1.5}\n          />\n        </svg>\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-[0.25em] size-[0.12em] -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400 dark:bg-zinc-600\"\n          style={{ top: \"var(--pluck-baseline, 1.56em)\" }}\n        />\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute right-[0.25em] size-[0.12em] translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400 dark:bg-zinc-600\"\n          style={{ top: \"var(--pluck-baseline, 1.56em)\" }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/plucked-text.tsx",
      "target": "components/kokonutui/plucked-text.tsx"
    }
  ]
}