{
  "name": "cradle-text",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Cradle Text\n * @version: 1.0.0\n * @date: 2026-10-15\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 CradleTextProps {\n  /** The line of letters hung from the rail. Keep it to one line. */\n  text?: string;\n  className?: string;\n  /** Lift the first letter and let it go once, the first time it's seen. */\n  intro?: boolean;\n  /** How much speed a collision keeps, 0 to 1. Steel is about 0.95. */\n  bounce?: number;\n}\n\n// Every letter hangs from the rail on a thread, a pendulum of the same\n// length, so it swings like the balls of a Newton's cradle: letters that\n// touch pass the knock along, and the one at the far end flies out.\nconst SUBSTEPS = 8;\nconst ITERATIONS = 3;\n// One full swing takes this long, whatever the font size: a desk toy with\n// strings about 12 cm long.\nconst PERIOD = 0.72;\n// Air drag, per second. Most of the energy is lost in the clacks; the last\n// small sway is damped harder so the cradle comes to rest in seconds.\nconst DRAG = 0.3;\nconst SETTLE_DRAG = 1.6;\nconst SETTLE_BELOW = 0.02;\n// Furthest a letter can be pulled out, in radians.\nconst MAX_ANGLE = 1.15;\n// A cursor sweeping through a letter knocks it, per px/s of its speed.\nconst SWEEP_PUSH = 0.22;\nconst MAX_SWEEP = 900;\n// A tap sends a letter out to about this angle.\nconst TAP_ANGLE = 0.38;\n// The intro pulls the first letter out this far, then lets go.\nconst INTRO_ANGLE = 0.62;\nconst INTRO_PULL = 520;\n// Below this energy (radians, roughly) the cradle is at rest.\nconst REST = 0.0004;\n\ninterface Bob {\n  el: HTMLElement;\n  /** Ink edges and centre, in stage px, with the letter hanging still. */\n  left: number;\n  right: number;\n  center: number;\n  top: number;\n  bottom: number;\n  /** Where the letter's own box sits, to rotate it about its pivot. */\n  boxLeft: number;\n  boxTop: number;\n  /** Space to the next letter at rest; they collide when it closes. */\n  gap: number;\n  phi: number;\n  omega: number;\n  held: boolean;\n}\n\nfunction clamp(x: number, min: number, max: number) {\n  return Math.min(Math.max(x, min), max);\n}\n\nfunction easeOut(t: number) {\n  return 1 - (1 - t) ** 4;\n}\n\nexport default function CradleText({\n  text = \"Momentum\",\n  className,\n  intro = true,\n  bounce = 0.97,\n}: CradleTextProps) {\n  const stageRef = useRef<HTMLDivElement>(null);\n  const lettersRef = useRef<HTMLSpanElement>(null);\n  const railRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const stage = stageRef.current;\n    const lettersEl = lettersRef.current;\n    const rail = railRef.current;\n    const ink = document.createElement(\"canvas\").getContext(\"2d\");\n    if (!(stage && lettersEl && rail && ink)) {\n      return;\n    }\n\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const spans = Array.from(\n      lettersEl.querySelectorAll<HTMLElement>(\"[data-letter]\")\n    );\n    const restitution = clamp(bounce, 0, 1);\n    const w0 = (2 * Math.PI) / PERIOD;\n\n    let bobs: Bob[] = [];\n    // Pivot height and the swing radius, measured to the letters' middle.\n    let railY = 0;\n    let radius = 1;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let pull: { bob: Bob; start: number; to: number } | null = null;\n    let hand: {\n      id: number;\n      bob: Bob;\n      startX: number;\n      startTime: number;\n      moved: boolean;\n      prev: number;\n      prevTime: number;\n      spin: number;\n    } | null = null;\n    let sweep: { x: number; t: number; over: Bob | null } | null = null;\n\n    const measure = () => {\n      const stageBox = stage.getBoundingClientRect();\n      const probe = lettersEl.lastElementChild as HTMLElement;\n      const baseline = probe.getBoundingClientRect().bottom - stageBox.top;\n      const style = getComputedStyle(lettersEl);\n      const em = Number.parseFloat(style.fontSize);\n      ink.font = style.font;\n      railY = rail.getBoundingClientRect().top - stageBox.top + 0.5;\n      radius = Math.max(baseline - em * 0.35 - railY, 1);\n      bobs = spans.map((el) => {\n        el.style.transform = \"\";\n        const box = el.getBoundingClientRect();\n        const m = ink.measureText(el.dataset.letter ?? \"\");\n        const boxLeft = box.left - stageBox.left;\n        const left = boxLeft - m.actualBoundingBoxLeft;\n        const right = boxLeft + m.actualBoundingBoxRight;\n        const top = baseline - m.actualBoundingBoxAscent;\n        const center = (left + right) / 2;\n        // The thread runs from the rail to the top of the ink.\n        const thread = el.firstElementChild as HTMLElement;\n        thread.style.left = `${center - boxLeft - 0.5}px`;\n        thread.style.top = `${railY - (box.top - stageBox.top)}px`;\n        thread.style.height = `${Math.max(top - railY, 0)}px`;\n        return {\n          el,\n          left,\n          right,\n          center,\n          top,\n          bottom: baseline + m.actualBoundingBoxDescent,\n          boxLeft,\n          boxTop: box.top - stageBox.top,\n          gap: 0,\n          phi: 0,\n          omega: 0,\n          held: false,\n        };\n      });\n      for (let i = 0; i + 1 < bobs.length; i++) {\n        bobs[i].gap = Math.max(bobs[i + 1].left - bobs[i].right, 0);\n      }\n      pull = null;\n      hand = null;\n    };\n\n    const draw = () => {\n      for (const bob of bobs) {\n        if (bob.phi === 0) {\n          bob.el.style.transform = \"\";\n          continue;\n        }\n        const px = bob.center - bob.boxLeft;\n        const py = railY - bob.boxTop;\n        // Swinging right turns the letter counter-clockwise about the rail.\n        bob.el.style.transform = `translate3d(${px.toFixed(2)}px, ${py.toFixed(2)}px, 0) rotate(${(-bob.phi).toFixed(4)}rad) translate3d(${(-px).toFixed(2)}px, ${(-py).toFixed(2)}px, 0)`;\n      }\n    };\n\n    const energy = () =>\n      bobs.reduce(\n        (e, b) => e + (b.omega / w0) ** 2 + 2 * (1 - Math.cos(b.phi)),\n        0\n      );\n\n    /** Bounce two touching letters apart if they're closing. */\n    const bounceApart = (a: Bob, b: Bob, wa: number, wb: number) => {\n      const ca = Math.max(Math.cos(a.phi), 0.2);\n      const cb = Math.max(Math.cos(b.phi), 0.2);\n      const va = a.omega * radius * ca;\n      const vb = b.omega * radius * cb;\n      const closing = va - vb;\n      if (closing <= 0) {\n        return;\n      }\n      const impulse = ((1 + restitution) * closing) / (wa + wb);\n      // A held letter (weight 0) keeps the hand's speed.\n      a.omega = (va - wa * impulse) / (radius * ca);\n      b.omega = (vb + wb * impulse) / (radius * cb);\n    };\n\n    /** Separate two letters that overlap and bounce them if they're closing. */\n    const collide = (a: Bob, b: Bob) => {\n      const xa = radius * Math.sin(a.phi);\n      const xb = radius * Math.sin(b.phi);\n      const depth = xa - xb - a.gap;\n      const wa = a.held ? 0 : 1;\n      const wb = b.held ? 0 : 1;\n      if (depth <= 0 || wa + wb === 0) {\n        return;\n      }\n      const share = depth / (wa + wb);\n      a.phi = Math.asin(clamp((xa - wa * share) / radius, -1, 1));\n      b.phi = Math.asin(clamp((xb + wb * share) / radius, -1, 1));\n      bounceApart(a, b, wa, wb);\n    };\n\n    const swing = (h: number, drag: number) => {\n      for (const bob of bobs) {\n        if (!bob.held) {\n          bob.omega -= w0 * w0 * Math.sin(bob.phi) * h;\n          bob.omega *= 1 - drag * h;\n          bob.phi += bob.omega * h;\n        }\n      }\n    };\n\n    const step = (dt: number) => {\n      const h = dt / SUBSTEPS;\n      const drag = energy() < SETTLE_BELOW ? SETTLE_DRAG : DRAG;\n      for (let s = 0; s < SUBSTEPS; s++) {\n        swing(h, drag);\n        // Sweep both ways so a knock travels the whole row in one step,\n        // the way it does through a real cradle.\n        for (let it = 0; it < ITERATIONS; it++) {\n          for (let i = 0; i + 1 < bobs.length; i++) {\n            collide(bobs[i], bobs[i + 1]);\n          }\n          for (let i = bobs.length - 2; i >= 0; i--) {\n            collide(bobs[i], bobs[i + 1]);\n          }\n        }\n      }\n    };\n\n    const runPull = (now: number) => {\n      if (!pull) {\n        return;\n      }\n      const t = (now - pull.start) / INTRO_PULL;\n      if (t >= 1.25) {\n        pull.bob.held = false;\n        pull.bob.omega = 0;\n        pull = null;\n        return;\n      }\n      pull.bob.held = true;\n      pull.bob.phi = pull.to * easeOut(Math.min(t, 1));\n      pull.bob.omega = 0;\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      runPull(now);\n      step(dt);\n      draw();\n      if (hand || pull || energy() > REST) {\n        raf = requestAnimationFrame(frame);\n        return;\n      }\n      for (const bob of bobs) {\n        bob.phi = 0;\n        bob.omega = 0;\n      }\n      draw();\n    };\n\n    const start = () => {\n      if (!raf && visible) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    /** Pointer in stage px. */\n    const local = (event: PointerEvent) => {\n      const r = stage.getBoundingClientRect();\n      return { x: event.clientX - r.left, y: event.clientY - r.top };\n    };\n\n    /** The letter under the pointer, where it hangs right now. */\n    const bobAt = (x: number, y: number, slack: number) =>\n      bobs.find((bob) => {\n        const shift = radius * Math.sin(bob.phi);\n        return (\n          x > bob.left + shift - slack &&\n          x < bob.right + shift + slack &&\n          y > bob.top - slack &&\n          y < bob.bottom + slack\n        );\n      });\n\n    const onDown = (event: PointerEvent) => {\n      if (hand || event.button !== 0) {\n        return;\n      }\n      const p = local(event);\n      const em = Number.parseFloat(getComputedStyle(lettersEl).fontSize);\n      const bob = bobAt(p.x, p.y, em * 0.1);\n      if (!bob) {\n        return;\n      }\n      stage.setPointerCapture(event.pointerId);\n      pull = null;\n      bob.held = true;\n      bob.omega = 0;\n      hand = {\n        id: event.pointerId,\n        bob,\n        startX: p.x - radius * Math.sin(bob.phi),\n        startTime: event.timeStamp,\n        moved: false,\n        prev: bob.phi,\n        prevTime: event.timeStamp,\n        spin: 0,\n      };\n      stage.dataset.grabbing = \"\";\n      start();\n    };\n\n    const onMove = (event: PointerEvent) => {\n      const p = local(event);\n      if (hand && event.pointerId === hand.id) {\n        const dx = p.x - hand.startX;\n        if (Math.abs(dx) > 3) {\n          hand.moved = true;\n        }\n        // The letter follows the finger sideways along its arc.\n        const phi = Math.asin(\n          clamp(dx / radius, -Math.sin(MAX_ANGLE), Math.sin(MAX_ANGLE))\n        );\n        const dt = Math.max(event.timeStamp - hand.prevTime, 8) / 1000;\n        const spin = (phi - hand.prev) / dt;\n        hand.spin = hand.spin * 0.4 + spin * 0.6;\n        hand.prev = phi;\n        hand.prevTime = event.timeStamp;\n        hand.bob.phi = phi;\n        // Carried into its neighbours, the hand pushes them along.\n        hand.bob.omega = hand.spin;\n        start();\n        return;\n      }\n      if (event.pointerType !== \"mouse\") {\n        sweep = null;\n        return;\n      }\n      const over = bobAt(p.x, p.y, 0) ?? null;\n      if (sweep && over && over !== sweep.over && !over.held) {\n        // Brushing through a letter knocks it the way the cursor moved.\n        const dt = Math.max(event.timeStamp - sweep.t, 8) / 1000;\n        const vx = clamp((p.x - sweep.x) / dt, -MAX_SWEEP, MAX_SWEEP);\n        over.omega += (SWEEP_PUSH * vx) / radius;\n        pull = null;\n        start();\n      }\n      sweep = { x: p.x, t: event.timeStamp, over };\n    };\n\n    const onUp = (event: PointerEvent) => {\n      if (!hand || event.pointerId !== hand.id) {\n        return;\n      }\n      const { bob } = hand;\n      bob.held = false;\n      delete stage.dataset.grabbing;\n      if (hand.moved) {\n        // Let go from still if the hand stopped before releasing.\n        const idle = event.timeStamp - hand.prevTime > 60;\n        bob.omega = idle ? 0 : clamp(hand.spin, -w0 * 1.5, w0 * 1.5);\n      } else {\n        // A tap knocks the letter away from the side it was tapped on.\n        const p = local(event);\n        const side = p.x < bob.center + radius * Math.sin(bob.phi) ? 1 : -1;\n        bob.omega += side * w0 * TAP_ANGLE;\n      }\n      hand = null;\n      start();\n    };\n\n    const onLeave = () => {\n      sweep = null;\n    };\n\n    measure();\n    draw();\n\n    let width = stage.clientWidth;\n    const resizeObserver = new ResizeObserver(() => {\n      if (stage.clientWidth === width) {\n        return;\n      }\n      width = stage.clientWidth;\n      measure();\n      draw();\n    });\n    resizeObserver.observe(stage);\n    document.fonts?.ready.then(() => {\n      measure();\n      draw();\n    });\n\n    let greeted = !intro || reduceMotion;\n    let greetTimer = 0;\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          greetTimer = window.setTimeout(() => {\n            const first = bobs[0];\n            if (first && !hand) {\n              pull = { bob: first, start: performance.now(), to: -INTRO_ANGLE };\n              start();\n            }\n          }, 400);\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    if (!reduceMotion) {\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\n    return () => {\n      cancelAnimationFrame(raf);\n      window.clearTimeout(greetTimer);\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  }, [intro, bounce]);\n\n  return (\n    <div className={cn(\"@container flex w-full justify-center\", className)}>\n      <div\n        className=\"relative cursor-grab touch-pan-y select-none px-[0.9em] pt-[1.5em] pb-[0.45em] 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        {/* The rail the letters hang from. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-[0.3em] h-px bg-zinc-300 dark:bg-zinc-700\"\n          ref={railRef}\n        />\n        <span\n          aria-hidden=\"true\"\n          className=\"relative block whitespace-pre\"\n          ref={lettersRef}\n        >\n          {Array.from(text).map((char, index) =>\n            char === \" \" ? (\n              // biome-ignore lint/suspicious/noArrayIndexKey: letters repeat; their position is their identity.\n              <span className=\"inline-block w-[0.32em]\" key={index} />\n            ) : (\n              <span\n                className=\"relative inline-block origin-top-left will-change-transform\"\n                data-letter={char}\n                // biome-ignore lint/suspicious/noArrayIndexKey: letters repeat; their position is their identity.\n                key={index}\n              >\n                <span className=\"absolute w-px bg-zinc-300 dark:bg-zinc-600\" />\n                {char}\n              </span>\n            )\n          )}\n          <span className=\"inline-block h-0 w-0\" />\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/cradle-text.tsx",
      "target": "components/kokonutui/cradle-text.tsx"
    }
  ]
}