{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-word-roll",
  "title": "Inline Word Roll",
  "description": "Roll through words beside a fixed prefix with accelerating timing and smoothly centered width.",
  "dependencies": ["remotion"],
  "files": [
    {
      "path": "registry/remocn/inline-word-roll/index.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  Easing,\n  useCurrentFrame,\n  useDelayRender,\n  useVideoConfig,\n} from \"remotion\";\n\nexport interface InlineWordRollProps {\n  prefix?: string;\n  /** Separate words or phrases with | or newlines. */\n  text?: string;\n  suffix?: string;\n  fontSize?: number;\n  fontFamily?: string;\n  fontWeight?: number;\n  color?: string;\n  /** Initial interval between switches, in frames. */\n  interval?: number;\n  /** Each following interval is multiplied by this number; 1 keeps a steady pace. */\n  acceleration?: number;\n  transitionFrames?: number;\n}\n\nexport const inlineWordRollDefaultText =\n  \"leads | customers | subscribers | appointments | demos | quotes | registrants | trials | conversions\";\n\nexport const parseInlineWords = (text: string) =>\n  text\n    .split(/[|\\n\\r]+/)\n    .map((word) => word.trim())\n    .filter(Boolean);\n\nconst finite = (value: number, fallback: number) =>\n  Number.isFinite(value) ? value : fallback;\nconst clamp = (n: number) => Math.max(0, Math.min(1, n));\nconst ease = Easing.bezier(0.22, 0.75, 0.25, 1);\n\nexport function getInlineWordRollState(\n  frame: number,\n  count: number,\n  interval = 12,\n  acceleration = 0.9,\n  transitionFrames = 6,\n) {\n  const transition = Math.max(1, finite(transitionFrames, 6));\n  const gap = Math.max(transition, finite(interval, 12));\n  const rate = Math.min(1, Math.max(0.5, finite(acceleration, 0.9)));\n  const f = Math.max(0, finite(frame, 0));\n  let start = 0;\n  for (let index = 1; index < count; index++) {\n    start += Math.max(transition, gap * rate ** (index - 1));\n    if (f < start) return { from: index - 1, to: index - 1, progress: 1 };\n    if (f < start + transition)\n      return {\n        from: index - 1,\n        to: index,\n        progress: clamp((f - start) / transition),\n      };\n  }\n  const last = Math.max(0, count - 1);\n  return { from: last, to: last, progress: 1 };\n}\n\nexport function getInlineWordRollDuration({\n  text = inlineWordRollDefaultText,\n  interval = 12,\n  acceleration = 0.9,\n  transitionFrames = 6,\n}: Pick<\n  InlineWordRollProps,\n  \"text\" | \"interval\" | \"acceleration\" | \"transitionFrames\"\n> = {}) {\n  const count = parseInlineWords(text).length;\n  const transition = Math.max(1, finite(transitionFrames, 6));\n  const gap = Math.max(transition, finite(interval, 12));\n  const rate = Math.min(1, Math.max(0.5, finite(acceleration, 0.9)));\n  let total = 0;\n  for (let index = 0; index < count - 1; index++)\n    total += Math.max(transition, gap * rate ** index);\n  return count < 2 ? 0 : Math.ceil(total + transition);\n}\n\nexport function InlineWordRoll({\n  prefix = \"Looking for\",\n  text = inlineWordRollDefaultText,\n  suffix = \"?\",\n  fontSize = 60,\n  fontFamily = \"Arial, Helvetica, sans-serif\",\n  fontWeight = 400,\n  color = \"#f5f5f5\",\n  interval = 12,\n  acceleration = 0.9,\n  transitionFrames = 6,\n}: InlineWordRollProps) {\n  const frame = useCurrentFrame();\n  const { width, height } = useVideoConfig();\n  const { delayRender, continueRender, cancelRender } = useDelayRender();\n  const words = parseInlineWords(text);\n  const measurement = useRef<HTMLDivElement>(null);\n  const pending = useRef<number | null>(null);\n  const [metrics, setMetrics] = useState<{\n    prefix: number;\n    words: number[];\n  } | null>(null);\n  const size = Math.max(1, finite(fontSize, 60));\n  const typography = {\n    fontFamily,\n    fontSize: size,\n    fontWeight,\n    letterSpacing: \"-0.035em\",\n    lineHeight: 1.2,\n    whiteSpace: \"pre\" as const,\n  };\n\n  useLayoutEffect(() => {\n    let cancelled = false;\n    const handle = delayRender(\"Measure Inline Word Roll font\");\n    pending.current = handle;\n    void document.fonts\n      .load(\n        `${fontWeight} ${size}px ${fontFamily}`,\n        `${prefix} ${text}${suffix}`,\n      )\n      .then(() => {\n        if (cancelled || !measurement.current) return;\n        const children = Array.from(measurement.current.children);\n        // Computed widths are in composition pixels. Bounding rectangles include\n        // the Player's preview scale and would shrink our layout a second time.\n        const measure = (node: Element) =>\n          Number.parseFloat(getComputedStyle(node).width);\n        setMetrics({\n          prefix: measure(children[0]),\n          words: children.slice(1).map(measure),\n        });\n      })\n      .catch((error: unknown) => {\n        if (!cancelled)\n          cancelRender(\n            error instanceof Error ? error : new Error(String(error)),\n          );\n      });\n    return () => {\n      cancelled = true;\n      continueRender(handle);\n      if (pending.current === handle) pending.current = null;\n    };\n  }, [\n    prefix,\n    text,\n    suffix,\n    size,\n    fontFamily,\n    fontWeight,\n    delayRender,\n    continueRender,\n    cancelRender,\n  ]);\n\n  useLayoutEffect(() => {\n    if (metrics && pending.current !== null) {\n      continueRender(pending.current);\n      pending.current = null;\n    }\n  }, [metrics, continueRender]);\n\n  const state = getInlineWordRollState(\n    frame,\n    words.length,\n    interval,\n    acceleration,\n    transitionFrames,\n  );\n  const moving = state.from !== state.to;\n  const p = ease(state.progress);\n  const gap = prefix ? size * 0.23 : 0;\n  const prefixWidth = metrics?.prefix ?? 0;\n  const fromWidth = metrics?.words[state.from] ?? 0;\n  const toWidth = metrics?.words[state.to] ?? 0;\n  const slotWidth = fromWidth + (toWidth - fromWidth) * p;\n  const totalWidth = prefixWidth + gap + slotWidth;\n  const widest = prefixWidth + gap + Math.max(0, ...(metrics?.words ?? []));\n  const fit = Math.min(\n    1,\n    (width * 0.84) / Math.max(1, widest),\n    (height * 0.5) / (size * 1.2),\n  );\n  const rowHeight = size * 1.2;\n\n  return (\n    <div\n      role=\"img\"\n      aria-label={[prefix, words[state.to] ? `${words[state.to]}${suffix}` : \"\"]\n        .filter(Boolean)\n        .join(\" \")}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", color }}\n    >\n      <div\n        ref={measurement}\n        aria-hidden=\"true\"\n        style={{\n          ...typography,\n          position: \"absolute\",\n          visibility: \"hidden\",\n          pointerEvents: \"none\",\n        }}\n      >\n        <span style={{ display: \"inline-block\" }}>{prefix}</span>\n        {words.map((word, index) => (\n          <span key={`${index}:${word}`} style={{ display: \"inline-block\" }}>\n            {word}\n            {suffix}\n          </span>\n        ))}\n      </div>\n      <div\n        aria-hidden=\"true\"\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          top: \"50%\",\n          scale: fit,\n          visibility: metrics && words.length ? \"visible\" : \"hidden\",\n        }}\n      >\n        <div\n          style={{\n            ...typography,\n            position: \"absolute\",\n            left: -totalWidth / 2,\n            top: -rowHeight / 2,\n            height: rowHeight,\n          }}\n        >\n          <span style={{ position: \"absolute\", left: 0 }}>{prefix}</span>\n          <div\n            style={{\n              position: \"absolute\",\n              left: prefixWidth + gap,\n              top: 0,\n              width: Math.max(fromWidth, toWidth) + size * 0.12,\n              height: rowHeight,\n              overflow: \"hidden\",\n              maskImage:\n                \"linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)\",\n            }}\n          >\n            <span\n              style={{\n                position: \"absolute\",\n                left: 0,\n                top: 0,\n                translate: `0 ${moving ? -p * rowHeight : 0}px`,\n                opacity: moving ? 1 - clamp(p * 1.2) : 1,\n              }}\n            >\n              {words[state.from]}\n              {words.length ? suffix : \"\"}\n            </span>\n            {moving ? (\n              <span\n                style={{\n                  position: \"absolute\",\n                  left: 0,\n                  top: 0,\n                  translate: `0 ${(1 - p) * rowHeight}px`,\n                  opacity: clamp(p * 1.6),\n                }}\n              >\n                {words[state.to]}\n                {suffix}\n              </span>\n            ) : null}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/inline-word-roll.tsx"
    }
  ],
  "type": "registry:component"
}
