{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "path-text",
  "title": "Path Text",
  "description": "Text starts along a loose path then straightens into a clean line with an italic tail.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/path-text/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/InstrumentSerif\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst serif = loadFont(\"normal\", { weights: [\"400\"], subsets: [\"latin\"] });\nconst serifItalic = loadFont(\"italic\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nexport interface PathTextProps {\n  text?: string;\n  /** Trailing word(s) of `text` set in italic, the editorial accent. */\n  italicWords?: number;\n  /** How loose the opening curve is, as a fraction of the canvas height. */\n  looseness?: number;\n  /** Kept for the shared draft API; the stroke takes the theme's gradient instead. */\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst THEMES = {\n  dark: { bg: \"#0A0A0B\", fg: \"#F3F2EF\" },\n  light: { bg: \"#F5F4F0\", fg: \"#121214\" },\n} as const;\n\n// One gradients.sh palette per theme, laid along the stroke left to right and\n// sliding a few percent as it draws. Dark: \"aurora\" (mint, teal, indigo,\n// orchid). Light: \"ribbon\" without its yellow, which would vanish on off-white.\nconst STROKE = {\n  dark: [\"#a0f0c8\", \"#3fb5a3\", \"#5147b6\", \"#b25c9c\"],\n  light: [\"#ff8a3d\", \"#ff3d9a\", \"#7a5cff\", \"#4a3cd6\"],\n} as const;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IO = Easing.bezier(0.65, 0, 0.35, 1);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst PROBE = 200;\nconst TRACK = -0.012;\n/**\n * The loose stroke, as y offsets in units of `looseness`: a low start, a\n * crest left of centre, a wide shallow valley, a flick up at the tail. The\n * valley is broad so the glyphs riding it never crowd into a tight bend.\n */\nconst SHAPE = [0.9, 0.35, -0.35, -0.75, -0.6, -0.12, 0.26, 0.3, 0.1];\nconst POINTS = SHAPE.length;\n\ninterface Pt {\n  x: number;\n  y: number;\n}\n\nfunction hash(n: number) {\n  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n  return s - Math.floor(s);\n}\n\n/** Catmull-Rom through the points, written as cubic Béziers. */\nfunction smoothPath(pts: Pt[]) {\n  let d = `M ${pts[0].x} ${pts[0].y}`;\n  for (let i = 0; i < pts.length - 1; i++) {\n    const p0 = pts[Math.max(0, i - 1)];\n    const p1 = pts[i];\n    const p2 = pts[i + 1];\n    const p3 = pts[Math.min(pts.length - 1, i + 2)];\n    const c1x = p1.x + (p2.x - p0.x) / 6;\n    const c1y = p1.y + (p2.y - p0.y) / 6;\n    const c2x = p2.x - (p3.x - p1.x) / 6;\n    const c2y = p2.y - (p3.y - p1.y) / 6;\n    d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.x} ${p2.y}`;\n  }\n  return d;\n}\n\nfunction splitItalic(text: string, italicWords: number) {\n  if (italicWords <= 0) return [text, \"\"] as const;\n  const parts = text.split(\" \");\n  const k = Math.max(0, parts.length - italicWords);\n  const roman = parts.slice(0, k).join(\" \");\n  const italic = parts.slice(k).join(\" \");\n  return [roman ? `${roman} ` : \"\", italic] as const;\n}\n\nfunction useTextWidth(roman: string, italic: string) {\n  const [width, setWidth] = useState<number | null>(null);\n  const [handle] = useState(() => delayRender(\"path-text: measuring\"));\n  const released = useRef(false);\n\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      serif.waitUntilDone(),\n      serifItalic.waitUntilDone(),\n      document.fonts.load(`400 100px \"${serif.fontFamily}\"`),\n      document.fonts.load(`italic 400 100px \"${serif.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n        let em = (roman.length + italic.length) * 0.42;\n        if (ctx) {\n          ctx.font = `400 ${PROBE}px \"${serif.fontFamily}\"`;\n          em = ctx.measureText(roman).width / PROBE;\n          ctx.font = `italic 400 ${PROBE}px \"${serif.fontFamily}\"`;\n          em += ctx.measureText(italic).width / PROBE;\n        }\n        setWidth(em + TRACK * [...roman, ...italic].length);\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [roman, italic, handle]);\n\n  return width;\n}\n\nexport function PathText({\n  text = \"From rough to ready.\",\n  italicWords = 1,\n  looseness = 0.15,\n  theme = \"dark\",\n  speed = 1,\n}: PathTextProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const u = W / 1280;\n  const id = `pt${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const gradId = `${id}g`;\n  const [roman, italic] = splitItalic(text, italicWords);\n  const em = useTextWidth(roman, italic);\n\n  if (em === null)\n    return <div style={{ position: \"absolute\", inset: 0, background: c.bg }} />;\n\n  const size = Math.min(H * 0.22, (W * 0.82) / em);\n  const textW = em * size;\n  const gap = size * 0.3;\n  const baseline = H / 2 + size * 0.24;\n  const lineY = baseline + gap;\n\n  // Beats: draw the loose stroke, write the words onto it, pull it taut.\n  const drawIn = interpolate(t, [0, 26], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const morphStart = 46;\n  const morphLen = 40;\n  const thin = interpolate(t, [morphStart + 6, morphStart + morphLen], [0, 1], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n\n  const amp = H * looseness;\n  const startSpan = W * 0.86;\n  const endSpan = textW + size * 0.7;\n  const pts: Pt[] = [];\n  for (let k = 0; k < POINTS; k++) {\n    const f = k / (POINTS - 1);\n    // Points straighten in a left-to-right ripple, like a string pulled taut.\n    const p = EASE_IO(\n      interpolate(\n        t,\n        [morphStart + f * 10, morphStart + morphLen - 10 + f * 10],\n        [0, 1],\n        CLAMP,\n      ),\n    );\n    const wobble = (hash(k + 3) - 0.5) * amp * 0.16;\n    const wave = SHAPE[k] * amp;\n    const sx =\n      W / 2 - startSpan / 2 + startSpan * f + (hash(k + 11) - 0.5) * 18 * u;\n    const sy = lineY + wave + wobble;\n    const ex = W / 2 - endSpan / 2 + endSpan * f;\n    pts.push({ x: sx + (ex - sx) * p, y: sy + (lineY - sy) * p });\n  }\n  const d = smoothPath(pts);\n\n  const stroke = interpolate(\n    thin,\n    [0, 1],\n    [size * 0.075, Math.max(3, size * 0.042)],\n  );\n  const romanCount = [...roman].length;\n  const glyphs = [...roman, ...italic];\n  const slide = interpolate(t, [0, 110], [0, 0.1], CLAMP);\n  const palette = STROKE[theme];\n  const gradSpan = startSpan + (endSpan - startSpan) * thin;\n  const writeStart = 14;\n\n  return (\n    <AbsoluteFill style={{ background: c.bg, overflow: \"hidden\" }}>\n      <svg\n        width={W}\n        height={H}\n        viewBox={`0 0 ${W} ${H}`}\n        style={{ position: \"absolute\", inset: 0 }}\n        aria-hidden\n      >\n        <defs>\n          <path id={id} d={d} />\n          <linearGradient\n            id={gradId}\n            gradientUnits=\"userSpaceOnUse\"\n            x1={W / 2 - gradSpan / 2}\n            y1={0}\n            x2={W / 2 + gradSpan / 2}\n            y2={0}\n          >\n            {palette.map((col, k) => (\n              <stop\n                key={col}\n                offset={Math.min(1, Math.max(0, k / 3 - 0.05 + slide))}\n                stopColor={col}\n              />\n            ))}\n          </linearGradient>\n        </defs>\n        <path\n          d={d}\n          pathLength={1}\n          fill=\"none\"\n          stroke={`url(#${gradId})`}\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          strokeDasharray=\"1 1\"\n          strokeDashoffset={1 - drawIn}\n        />\n        <text\n          fontFamily={serif.fontFamily}\n          fontSize={size}\n          letterSpacing={`${TRACK}em`}\n          fill={c.fg}\n          dy={-interpolate(thin, [0, 1], [size * 0.06, gap])}\n        >\n          <textPath href={`#${id}`} startOffset=\"50%\" textAnchor=\"middle\">\n            {glyphs.map((g, i) => {\n              const o = interpolate(\n                t,\n                [writeStart + i * 2, writeStart + i * 2 + 8],\n                [0, 1],\n                { ...CLAMP, easing: EASE_OUT },\n              );\n              return (\n                <tspan\n                  key={i}\n                  opacity={o}\n                  fontStyle={i >= romanCount ? \"italic\" : undefined}\n                >\n                  {g}\n                </tspan>\n              );\n            })}\n          </textPath>\n        </text>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/path-text.tsx"
    }
  ],
  "type": "registry:component"
}
