{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elastic-headline",
  "title": "Elastic Headline",
  "description": "A headline strung on an elastic. Letters are nodes on a mass-spring chain pinned at both ends and coupled by the discrete Laplacian, so a pull travels outward and reflects off the ends — grab one and the whole line deforms, then rings out.",
  "dependencies": [],
  "files": [
    {
      "path": "src/components/ElasticHeadline/index.js",
      "content": "export { default } from './ElasticHeadline.jsx'\n",
      "type": "registry:component",
      "target": "components/elastic-headline/index.js"
    },
    {
      "path": "src/components/ElasticHeadline/ElasticHeadline.jsx",
      "content": "'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport './ElasticHeadline.css'\n\n/* =========================================================\n   ElasticHeadline\n   A headline strung on an elastic. Grab a letter and drag —\n   the whole line deforms behind your hand, and letting go\n   lets it ring out through its own modes before settling.\n\n   The letters are nodes on a one-dimensional mass-spring\n   chain, pinned at both ends. Each node feels three forces:\n\n     · its neighbours          k(xᵢ₋₁ − 2xᵢ + xᵢ₊₁)\n     · a pull back to its slot  −kₐ·xᵢ\n     · damping                  −c·vᵢ\n\n   The first term is the discrete Laplacian, which is what\n   makes this a wave rather than a row of independent springs.\n   Displace one node and the disturbance travels outward to\n   its neighbours and reflects off the pinned ends — that\n   travelling-and-reflecting is the whole reason it reads as\n   one object rather than as letters that each happen to\n   wobble. A row of independent springs gives you a Mexican\n   wave; a coupled chain gives you a guitar string.\n\n   Integration is semi-implicit Euler at a fixed 1/120 s\n   substep, accumulated against real frame time. Explicit\n   Euler blows up on stiff chains — the energy grows every\n   step — and a variable dt makes the stiffness effectively\n   frame-rate dependent, so the same drag would ring\n   differently on a 60Hz and a 120Hz display.\n   ========================================================= */\n\nconst SUBSTEP = 1 / 120\n\nexport default function ElasticHeadline({\n  text = 'Interfaces that move',\n\n  /* --- the string --- */\n  stiffness = 900,      // neighbour coupling\n  anchor = 120,         // pull back to the resting slot\n  damping = 5.2,\n  mass = 1,\n\n  /* --- what a drag does --- */\n  grab = 1,             // how much of the pointer's travel a held letter takes\n  reach = 2,            // how far a nudge spreads on hover, in letters\n  nudge = 0,            // vertical push from hovering, px (0 = drag only)\n  lift = 0.35,          // how much vertical displacement tilts a glyph\n\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n\n  const chars = useMemo(() => Array.from(text), [text])\n\n  const live = useRef({})\n  live.current = { stiffness, anchor, damping, mass, grab, reach, nudge, lift }\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n\n    const cells = Array.from(root.querySelectorAll('.elastic-headline__char'))\n    if (!cells.length) return\n\n    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    const n = cells.length\n\n    // Vertical displacement and velocity, one per letter. Both ends are pinned.\n    const y = new Float32Array(n)\n    const v = new Float32Array(n)\n    let centres = []          // resting x of each letter, viewport space\n    let baseY = 0\n\n    let held = -1             // index of the letter under a held pointer\n    let heldY = 0             // where that pointer wants it\n    let hoverX = -1e5\n    let raf = 0\n    let last = 0\n    let acc = 0\n    let onScreen = true\n\n    const measure = () => {\n      centres = cells.map((el) => {\n        const b = el.getBoundingClientRect()\n        return b.left + b.width / 2\n      })\n      const b = root.getBoundingClientRect()\n      baseY = b.top + b.height / 2\n    }\n\n    const nearest = (clientX) => {\n      let best = 0\n      let bd = Infinity\n      for (let i = 0; i < centres.length; i++) {\n        const d = Math.abs(centres[i] - clientX)\n        if (d < bd) { bd = d; best = i }\n      }\n      return best\n    }\n\n    const paint = () => {\n      const { lift: lk } = live.current\n      for (let i = 0; i < n; i++) {\n        // Tilt from the local slope, so a letter leans into the curve it sits on.\n        const l = y[i - 1] ?? 0\n        const r = y[i + 1] ?? 0\n        const rot = Math.atan2((r - l) * lk, 24)\n        cells[i].style.transform =\n          `translateY(${y[i].toFixed(2)}px) rotate(${rot.toFixed(4)}rad)`\n      }\n    }\n\n    const step = (dt) => {\n      const { stiffness: k, anchor: ka, damping: c, mass: m, grab: g } = live.current\n      const im = 1 / Math.max(0.05, m)\n\n      for (let i = 0; i < n; i++) {\n        if (i === 0 || i === n - 1) { y[i] = 0; v[i] = 0; continue }\n        if (i === held) {\n          // A held letter is kinematic: it goes where the hand goes, and the\n          // chain has to deal with it. Its velocity is kept so that letting go\n          // hands the string real momentum instead of dropping it dead.\n          const want = heldY * g\n          v[i] = (want - y[i]) / Math.max(dt, 1e-4)\n          y[i] = want\n          continue\n        }\n        const left = y[i - 1]\n        const right = y[i + 1]\n        // Discrete Laplacian — this is the term that makes it a wave.\n        const a = (k * (left - 2 * y[i] + right) - ka * y[i] - c * v[i]) * im\n        v[i] += a * dt\n      }\n\n      for (let i = 1; i < n - 1; i++) {\n        if (i === held) continue\n        y[i] += v[i] * dt\n      }\n    }\n\n    const settled = () => {\n      if (held >= 0) return false\n      for (let i = 1; i < n - 1; i++) {\n        if (Math.abs(v[i]) > 0.6 || Math.abs(y[i]) > 0.08) return false\n      }\n      return true\n    }\n\n    const frame = (now) => {\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : SUBSTEP\n      last = now\n\n      /* Fixed substeps against real frame time: the chain is stiff, and a\n         variable dt would make the same drag ring differently at 60 and 120Hz. */\n      acc = Math.min(acc + dt, 0.25)\n      while (acc >= SUBSTEP) {\n        step(SUBSTEP)\n        acc -= SUBSTEP\n      }\n      paint()\n\n      if (settled()) {\n        y.fill(0)\n        v.fill(0)\n        paint()\n        raf = 0\n        last = 0\n        acc = 0\n        return\n      }\n      raf = requestAnimationFrame(frame)\n    }\n\n    const kick = () => {\n      if (raf || !onScreen || still) return\n      last = 0\n      raf = requestAnimationFrame(frame)\n    }\n\n    const onDown = (e) => {\n      measure()\n      held = nearest(e.clientX)\n      heldY = e.clientY - baseY\n      root.setPointerCapture?.(e.pointerId)\n      root.classList.add('is-held')\n      kick()\n    }\n\n    const onMove = (e) => {\n      if (held >= 0) {\n        heldY = e.clientY - baseY\n        kick()\n        return\n      }\n      // Hovering only does anything if `nudge` is on.\n      const { nudge: nu, reach: rr } = live.current\n      if (!nu) return\n      hoverX = e.clientX\n      const i = nearest(hoverX)\n      for (let j = Math.max(1, i - rr); j <= Math.min(n - 2, i + rr); j++) {\n        const f = 1 - Math.abs(j - i) / (rr + 1)\n        v[j] += nu * f\n      }\n      kick()\n    }\n\n    const onUp = (e) => {\n      if (held < 0) return\n      held = -1\n      root.releasePointerCapture?.(e.pointerId)\n      root.classList.remove('is-held')\n      kick()\n    }\n\n    const io = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting\n      if (onScreen) kick()\n      else if (raf) { cancelAnimationFrame(raf); raf = 0 }\n    })\n    io.observe(root)\n\n    const ro = new ResizeObserver(() => { measure(); paint() })\n    ro.observe(root)\n\n    measure()\n    paint()\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(); paint() } })\n\n    return () => {\n      dead = true\n      io.disconnect()\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])\n\n  return (\n    <span\n      ref={rootRef}\n      className={`elastic-headline ${className}`.trim()}\n      style={style}\n      /* Every glyph is hidden below, so this is the only accessible name —\n         and a bare span's generic role cannot carry one. */\n      role=\"img\"\n      aria-label={text}\n      {...rest}\n    >\n      {chars.map((ch, i) => (\n        <span\n          key={i}\n          className={`elastic-headline__char${ch === ' ' ? ' is-space' : ''}`}\n          aria-hidden=\"true\"\n        >\n          {ch === ' ' ? ' ' : ch}\n        </span>\n      ))}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/elastic-headline/ElasticHeadline.jsx"
    },
    {
      "path": "src/components/ElasticHeadline/ElasticHeadline.css",
      "content": "/* ElasticHeadline — JS writes one transform per letter and nothing else. The\n   letters are inline-block so they can be transformed while the line still\n   wraps and justifies like ordinary text. */\n\n.elastic-headline {\n  display: inline-block;\n  cursor: grab;\n  user-select: none;\n  touch-action: none;\n}\n\n.elastic-headline.is-held { cursor: grabbing; }\n\n.elastic-headline__char {\n  display: inline-block;\n  /* Bottom-centre, so a tilted glyph pivots on its baseline rather than\n     swinging about its middle and drifting off the line. */\n  transform-origin: 50% 90%;\n  will-change: transform;\n}\n\n/* A space has no glyph to transform, but it still has to hold its width or the\n   line would close up as its neighbours move. */\n.elastic-headline__char.is-space {\n  white-space: pre;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .elastic-headline { cursor: default; }\n  .elastic-headline__char { will-change: auto; }\n}\n",
      "type": "registry:file",
      "target": "components/elastic-headline/ElasticHeadline.css"
    },
    {
      "path": "src/components/ElasticHeadline/ElasticHeadline.d.ts",
      "content": "import type { ComponentPropsWithoutRef } from 'react'\n\nexport interface ElasticHeadlineProps extends ComponentPropsWithoutRef<'span'> {\n  /** Rendered per glyph, and used as the element's accessible name. */\n  text?: string\n\n  /** Neighbour coupling. This is the term that makes the line a wave. */\n  stiffness?: number\n  /** Pull back to the resting slot. Higher settles flatter, faster. */\n  anchor?: number\n  damping?: number\n  /** Per-letter mass. Heavier rings lower and longer. */\n  mass?: number\n\n  /** How much of the pointer's travel a held letter takes, 0..1. */\n  grab?: number\n  /** How far a hover nudge spreads, in letters. */\n  reach?: number\n  /** Vertical push from hovering, in px. 0 makes the component drag-only. */\n  nudge?: number\n  /** How much vertical displacement tilts a glyph. */\n  lift?: number\n}\n\ndeclare const ElasticHeadline: (props: ElasticHeadlineProps) => JSX.Element\nexport default ElasticHeadline\n",
      "type": "registry:file",
      "target": "components/elastic-headline/ElasticHeadline.d.ts"
    }
  ],
  "type": "registry:component"
}