{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kinetic-morph-text",
  "title": "Kinetic Morph Text",
  "description": "Letters lift, rotate, and softly reshape into editable phrases with sampled outline trails.",
  "dependencies": ["remotion"],
  "files": [
    {
      "path": "registry/remocn/kinetic-morph-text/index.tsx",
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useMemo, useState } from \"react\";\nimport { Easing, useCurrentFrame, useVideoConfig } from \"remotion\";\n\nexport interface KineticMorphTextProps {\n  /** One phrase per line, or separate phrases with |. */\n  text?: string;\n  fontSize?: number;\n  fontWeight?: number;\n  color?: string;\n  backgroundColor?: string;\n  /** Flight radius in composition pixels. */\n  spread?: number;\n  /** Maximum letter rotation in degrees. */\n  rotation?: number;\n  /** Strength of the soft silhouette blend, from 0 to 1. */\n  morph?: number;\n  /** Opacity of the sampled outline echoes, from 0 to 1. */\n  trails?: number;\n  transitionFrames?: number;\n  holdFrames?: number;\n  loop?: boolean;\n  speed?: number;\n  className?: string;\n}\n\nexport const kineticMorphTextDefaultText =\n  \"Hello | Make it move | Shape what's next\";\n\nconst FONT_FAMILY = \"Arial, Helvetica, sans-serif\";\nconst TRANSITION = 54;\nconst HOLD = 42;\nconst clamp = (value: number) => Math.min(1, Math.max(0, value));\nconst finite = (value: number, fallback: number) =>\n  Number.isFinite(value) ? value : fallback;\nconst ease = Easing.bezier(0.65, 0, 0.25, 1);\nconst smooth = (value: number) => {\n  const p = clamp(value);\n  return p * p * (3 - 2 * p);\n};\nconst mix = (a: number, b: number, p: number) => a + (b - a) * p;\nconst noise = (index: number) => {\n  const value = Math.sin(index * 127.1 + 311.7) * 43758.5453;\n  return value - Math.floor(value);\n};\n\nexport function parseKineticMorphText(text: string): string[] {\n  return text\n    .split(/[|\\n\\r]+/)\n    .map((phrase) => phrase.trim().replace(/\\s+/g, \" \"))\n    .filter(Boolean);\n}\n\n/** Includes the last readable hold. Timing values are authored at 30 fps. */\nexport function getKineticMorphTextDuration({\n  text = kineticMorphTextDefaultText,\n  transitionFrames = TRANSITION,\n  holdFrames = HOLD,\n  speed = 1,\n}: Pick<\n  KineticMorphTextProps,\n  \"text\" | \"transitionFrames\" | \"holdFrames\" | \"speed\" | \"loop\"\n> = {}): number {\n  const count = parseKineticMorphText(text).length;\n  const transition = Math.max(1, finite(transitionFrames, TRANSITION));\n  const hold = Math.max(0, finite(holdFrames, HOLD));\n  const rate = Math.max(0.01, finite(speed, 1));\n  // Both modes use one transition + hold per phrase. Loop mode starts with\n  // a readable first phrase and spends its last transition returning to it.\n  const frames = count * (transition + hold);\n  return Math.max(1, Math.ceil(frames / rate));\n}\n\n/** Last moving frame at defaults; the demo adds a final 42-frame hold. */\nexport const kineticMorphTextLength = 246;\n\nexport interface KineticMorphGlyph {\n  character: string;\n  x: number;\n}\n\nexport interface KineticMorphPair {\n  from?: KineticMorphGlyph;\n  to?: KineticMorphGlyph;\n}\n\n/** Reserve identical graphemes before pairing remaining replacements. */\nexport function matchKineticMorphGlyphs(\n  from: KineticMorphGlyph[],\n  to: KineticMorphGlyph[],\n): KineticMorphPair[] {\n  const unused = new Set(from.map((_, index) => index));\n  const matches = to.map((target) => {\n    let closest = -1;\n    let distance = Number.POSITIVE_INFINITY;\n    for (const index of unused) {\n      const source = from[index];\n      if (source.character !== target.character) continue;\n      const travel = Math.abs(source.x - target.x);\n      if (travel < distance) {\n        closest = index;\n        distance = travel;\n      }\n    }\n    if (closest !== -1) unused.delete(closest);\n    return closest;\n  });\n  const remaining = [...unused];\n  const pairs: KineticMorphPair[] = to.map((target, index) => {\n    const source = matches[index] === -1 ? remaining.shift() : matches[index];\n    return {\n      from: source === undefined ? undefined : from[source],\n      to: target,\n    };\n  });\n  for (const index of remaining) pairs.push({ from: from[index] });\n  return pairs;\n}\n\nexport function getKineticMorphTimeline(\n  frame: number,\n  count: number,\n  transitionFrames = TRANSITION,\n  holdFrames = HOLD,\n  loop = false,\n) {\n  const transition = Math.max(1, finite(transitionFrames, TRANSITION));\n  const hold = Math.max(0, finite(holdFrames, HOLD));\n  const time = Math.max(0, finite(frame, 0));\n  if (count === 0) return { from: -1, to: -1, progress: 1 };\n  if (!loop && time < transition) {\n    return { from: -1, to: 0, progress: time / transition };\n  }\n  const cursor = loop ? time : time - transition;\n  const cycle = transition + hold;\n  const index = Math.floor(cursor / cycle);\n  if ((!loop && index >= count - 1) || (loop && count === 1)) {\n    return { from: count - 1, to: count - 1, progress: 1 };\n  }\n  return {\n    from: index % count,\n    to: (index + 1) % count,\n    progress: clamp(((cursor % cycle) - hold) / transition),\n  };\n}\n\ninterface PoseOptions {\n  spread: number;\n  rotation: number;\n  morph: number;\n}\n\nexport function getKineticMorphPose(\n  pair: KineticMorphPair,\n  index: number,\n  count: number,\n  progress: number,\n  { spread, rotation, morph }: PoseOptions,\n) {\n  // A bounded stagger keeps long phrases within the same transition window.\n  const delay = noise(index + 3) * 0.12;\n  const p = clamp((progress - delay) / (1 - delay));\n  const travel = ease(p);\n  const motion = Math.sin(Math.PI * travel);\n  const angle = (index / Math.max(count, 1)) * Math.PI * 2 - Math.PI / 2;\n  const orbitX = Math.cos(angle) * spread * (0.7 + noise(index) * 0.4);\n  const orbitY = Math.sin(angle) * spread * 0.72 - spread * 0.25;\n  const fromX = pair.from?.x ?? orbitX * 1.2;\n  const toX = pair.to?.x ?? orbitX * 1.2;\n  const fromY = pair.from ? 0 : orbitY + spread * 0.65;\n  const toY = pair.to ? 0 : orbitY - spread * 0.6;\n  const x = mix(fromX, toX, travel) + orbitX * motion;\n  const y = mix(fromY, toY, travel) + orbitY * motion;\n  const turn = (noise(index + 9) * 2 - 1) * rotation;\n  const rotate = pair.from\n    ? pair.to\n      ? turn * motion\n      : turn * travel\n    : turn * (1 - travel);\n  const replacement = Boolean(\n    pair.from && pair.to && pair.from.character !== pair.to.character,\n  );\n  const blend = replacement ? smooth((p - 0.36) / 0.28) : 0;\n  const melt = replacement ? Math.sin(Math.PI * blend) * morph : 0;\n  const opacity = pair.from\n    ? pair.to\n      ? 1\n      : 1 - smooth((p - 0.35) / 0.5)\n    : smooth(p / 0.36);\n  return {\n    x,\n    y,\n    rotate,\n    scaleX: 1 - melt * 0.52 + motion * 0.08,\n    scaleY: 1 + melt * 0.42 - motion * 0.06,\n    blend,\n    melt,\n    opacity,\n    motion,\n  };\n}\n\ninterface Layout {\n  key: string;\n  phrases: KineticMorphGlyph[][];\n  size: number;\n  baseline: number;\n}\n\nfunction useGlyphLayout(\n  text: string,\n  size: number,\n  weight: number,\n  width: number,\n) {\n  const [layout, setLayout] = useState<Layout | null>(null);\n  const key = JSON.stringify([text, size, weight, width]);\n\n  useLayoutEffect(() => {\n    const context = document.createElement(\"canvas\").getContext(\"2d\");\n    if (!context) return;\n    const segmenter = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\n    const phrases = parseKineticMorphText(text).map((phrase) =>\n      Array.from(segmenter.segment(phrase), ({ segment }) => segment),\n    );\n    context.font = `${weight} ${size}px ${FONT_FAMILY}`;\n    context.fontKerning = \"none\";\n    const advances = phrases.map((phrase) =>\n      phrase.map((character) => context.measureText(character).width),\n    );\n    const widths = advances.map((row) =>\n      row.reduce((sum, value) => sum + value, 0),\n    );\n    const fit = Math.min(1, (width * 0.82) / Math.max(1, ...widths));\n    const metrics = context.measureText(\"Hg\");\n    setLayout({\n      key,\n      size: size * fit,\n      baseline:\n        ((metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) /\n          2) *\n        fit,\n      phrases: phrases.map((phrase, row) => {\n        let cursor = (-widths[row] * fit) / 2;\n        return phrase.flatMap((character, column) => {\n          const advance = advances[row][column] * fit;\n          const x = cursor + advance / 2;\n          cursor += advance;\n          return character.trim() ? [{ character, x }] : [];\n        });\n      }),\n    });\n  }, [key, text, size, weight, width]);\n\n  return layout?.key === key ? layout : null;\n}\n\nfunction MorphGlyph({\n  pair,\n  index,\n  count,\n  progress,\n  options,\n  size,\n  baseline,\n  color,\n  trails,\n  transitionFrames,\n}: {\n  pair: KineticMorphPair;\n  index: number;\n  count: number;\n  progress: number;\n  options: PoseOptions;\n  size: number;\n  baseline: number;\n  color: string;\n  trails: number;\n  transitionFrames: number;\n}) {\n  const filterId = useId().replace(/:/g, \"\");\n  const pose = getKineticMorphPose(pair, index, count, progress, options);\n  const transform = (value: typeof pose) =>\n    `translate(${value.x} ${value.y}) rotate(${value.rotate}) scale(${value.scaleX} ${value.scaleY})`;\n  const replacement =\n    pair.from && pair.to && pair.from.character !== pair.to.character;\n  const character = pair.from?.character ?? pair.to?.character;\n\n  return (\n    <g>\n      {trails > 0 && pose.motion > 0.001\n        ? [3, 2, 1].map((sample) => {\n            const echo = getKineticMorphPose(\n              pair,\n              index,\n              count,\n              Math.max(0, progress - (sample * 1.5) / transitionFrames),\n              options,\n            );\n            return (\n              <text\n                key={sample}\n                transform={transform(echo)}\n                y={baseline}\n                fill=\"none\"\n                stroke={color}\n                strokeWidth={Math.max(0.7, size * 0.009)}\n                opacity={echo.opacity * pose.motion * trails * (1 - sample / 4)}\n              >\n                {replacement && echo.blend > 0.5\n                  ? pair.to?.character\n                  : character}\n              </text>\n            );\n          })\n        : null}\n      {replacement && pose.melt > 0.001 ? (\n        <defs>\n          <filter\n            id={filterId}\n            x=\"-70%\"\n            y=\"-70%\"\n            width=\"240%\"\n            height=\"240%\"\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur stdDeviation={pose.melt * size * 0.045} />\n            <feColorMatrix\n              type=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 12 -4\"\n            />\n          </filter>\n        </defs>\n      ) : null}\n      <g transform={transform(pose)} opacity={pose.opacity}>\n        <g\n          filter={\n            replacement && pose.melt > 0.001 ? `url(#${filterId})` : undefined\n          }\n          fill={color}\n        >\n          <text y={baseline} opacity={replacement ? 1 - pose.blend : 1}>\n            {character}\n          </text>\n          {replacement ? (\n            <text y={baseline} opacity={pose.blend}>\n              {pair.to?.character}\n            </text>\n          ) : null}\n        </g>\n      </g>\n    </g>\n  );\n}\n\nexport function KineticMorphText({\n  text = kineticMorphTextDefaultText,\n  fontSize = 100,\n  fontWeight = 600,\n  color = \"#b4f4d9\",\n  backgroundColor = \"#a800b7\",\n  spread = 180,\n  rotation = 110,\n  morph = 0.75,\n  trails = 0.45,\n  transitionFrames = TRANSITION,\n  holdFrames = HOLD,\n  loop = false,\n  speed = 1,\n  className,\n}: KineticMorphTextProps) {\n  const currentFrame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const frame = currentFrame * (30 / fps) * Math.max(0, finite(speed, 1));\n  const size = Math.min(height * 0.28, Math.max(1, finite(fontSize, 100)));\n  const weight = Math.min(900, Math.max(100, finite(Number(fontWeight), 600)));\n  const layout = useGlyphLayout(text, size, weight, width);\n  const transition = Math.max(1, finite(transitionFrames, TRANSITION));\n  const timeline = getKineticMorphTimeline(\n    frame,\n    layout?.phrases.length ?? 0,\n    transition,\n    holdFrames,\n    loop,\n  );\n  const pairs = useMemo(\n    () =>\n      matchKineticMorphGlyphs(\n        layout?.phrases[timeline.from] ?? [],\n        layout?.phrases[timeline.to] ?? [],\n      ),\n    [layout, timeline.from, timeline.to],\n  );\n  const options = {\n    spread: Math.min(\n      width * 0.24,\n      height * 0.32,\n      Math.max(0, finite(spread, 180)),\n    ),\n    rotation: Math.max(0, finite(rotation, 110)),\n    morph: clamp(finite(morph, 0.75)),\n  };\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        backgroundColor,\n        overflow: \"hidden\",\n      }}\n    >\n      <svg\n        width=\"100%\"\n        height=\"100%\"\n        viewBox={`0 0 ${width} ${height}`}\n        role=\"img\"\n        aria-label={parseKineticMorphText(text).join(\". \")}\n        style={{\n          display: \"block\",\n          fontFamily: FONT_FAMILY,\n          fontSize: layout?.size ?? size,\n          fontWeight: weight,\n          fontKerning: \"none\",\n        }}\n      >\n        <g\n          transform={`translate(${width / 2} ${height / 2})`}\n          textAnchor=\"middle\"\n        >\n          {layout\n            ? pairs.map((pair, index) => (\n                <MorphGlyph\n                  key={`${timeline.from}:${timeline.to}:${index}`}\n                  pair={pair}\n                  index={index}\n                  count={pairs.length}\n                  progress={timeline.progress}\n                  options={options}\n                  size={layout.size}\n                  baseline={layout.baseline}\n                  color={color}\n                  trails={clamp(finite(trails, 0.45))}\n                  transitionFrames={transition}\n                />\n              ))\n            : null}\n        </g>\n      </svg>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/kinetic-morph-text.tsx"
    }
  ],
  "type": "registry:component"
}
