{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "path-ride",
  "title": "Path Ride",
  "description": "A line draws itself along a curve with the text riding behind its head, then the curve pulls taut and the text settles on a straight, centered baseline.",
  "dependencies": ["remotion", "@remotion/google-fonts", "@remotion/paths"],
  "files": [
    {
      "path": "registry/remocn/path-ride/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport {\n  getLength,\n  getPointAtLength,\n  getTangentAtLength,\n} from \"@remotion/paths\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: INTER } = loadFont(\"normal\", {\n  weights: [\"100\", \"900\"],\n  subsets: [\"latin\"],\n});\n\nexport interface PathRideProps {\n  text?: string;\n  path?: string;\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  color?: string;\n  lineColor?: string;\n  lineWidth?: number;\n  speed?: number;\n  className?: string;\n}\n\n/** An S-curve across a 1280×720 frame. */\nexport const pathRideDefaultPath =\n  \"M 120 560 C 360 120 560 120 640 360 S 920 600 1160 160\";\n\nexport const pathRideDefaults = {\n  text: \"Follow the line\",\n  path: pathRideDefaultPath,\n  fontSize: 44,\n  fontWeight: 700,\n  fontFamily: INTER,\n  color: \"#fafafa\",\n  lineColor: \"#D97757\",\n  lineWidth: 3,\n  speed: 1,\n} satisfies Required<Omit<PathRideProps, \"className\">>;\n\n/** Beats on the 30 fps clock. */\nexport const pathRideTiming = {\n  draw: [0, 40],\n  straighten: [50, 68],\n  erase: [52, 70],\n} as const;\n\n/** Gap between the head and the last letter, in em. */\nconst LEAD = 0.3;\n/** Letters fade in over this much arc length, in em. */\nconst FADE_IN = 0.5;\n/** Extra lift of the baseline above the line, in em. */\nconst LIFT = 0.08;\n/** Samples along the path; the line and the letters read positions from them. */\nconst SAMPLES = 400;\nconst MEASURE_SIZE = 100;\n/** Inter's baseline and cap height in a `line-height: 1` box, until measured. */\nconst INTER_METRICS = { baseline: 0.8637, capHeight: 0.7275 };\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst easeInOutCubic = Easing.inOut(Easing.cubic);\n\nfunction finite(value: number | undefined, fallback: number) {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : fallback;\n}\n\n/** Maps a composition frame onto the 30 fps timeline, scaled by `speed`. */\nexport function getPathRideTime(\n  frame: number,\n  options: { fps?: number; speed?: number } = {},\n) {\n  const fps = Math.max(1, finite(options.fps, 30));\n  const speed = Math.max(0, finite(options.speed, 1));\n  return (Math.max(0, finite(frame, 0)) * 30 * speed) / fps;\n}\n\n/** Arc length drawn so far. */\nexport function getPathRideHead(t: number, length: number) {\n  return interpolate(t, [...pathRideTiming.draw], [0, length], {\n    ...CLAMP,\n    easing: easeInOutCubic,\n  });\n}\n\n/** Arc length erased from the start. */\nexport function getPathRideTail(t: number, length: number) {\n  return interpolate(t, [...pathRideTiming.erase], [0, length], {\n    ...CLAMP,\n    easing: easeInOutCubic,\n  });\n}\n\n/** How far the curve has pulled straight, 0..1. */\nexport function getPathRideStraighten(t: number) {\n  return interpolate(t, [...pathRideTiming.straighten], [0, 1], {\n    ...CLAMP,\n    easing: easeInOutCubic,\n  });\n}\n\n/** Frame where the line has erased and the text rests on its baseline. */\nexport const pathRideLength = Math.max(\n  pathRideTiming.straighten[1],\n  pathRideTiming.erase[1],\n);\n\n/** Letter centers from the text start, and the total width. */\nexport function getPathRideCenters(advances: readonly number[]) {\n  let x = 0;\n  const centers = advances.map((a) => {\n    const c = x + a / 2;\n    x += a;\n    return c;\n  });\n  return { centers, width: x };\n}\n\n/**\n * Arc length of a letter's center: it trails the head by the lead plus the\n * text after it, so the last letter follows the head most closely.\n */\nexport function getPathRideArc(\n  head: number,\n  center: number,\n  width: number,\n  fontSize: number,\n) {\n  return head - LEAD * fontSize - (width - center);\n}\n\n/** Opacity of a letter from how far onto the curve it has come. */\nexport function getPathRideFade(arc: number, fontSize: number) {\n  return Math.min(1, Math.max(0, arc / (FADE_IN * fontSize)));\n}\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport interface PathSamples {\n  length: number;\n  points: Point[];\n  tangents: Point[];\n}\n\n/** Points and unit tangents at evenly spaced arc lengths along `path`. */\nexport function samplePath(path: string, count = SAMPLES): PathSamples {\n  const length = getLength(path);\n  const n = Math.max(2, Math.round(count));\n  const points: Point[] = [];\n  const tangents: Point[] = [];\n  for (let i = 0; i < n; i++) {\n    const at = (length * i) / (n - 1);\n    // Both can come back null on a degenerate path; reuse the last sample.\n    const p = getPointAtLength(path, at) ?? points[i - 1] ?? { x: 0, y: 0 };\n    const tangent = getTangentAtLength(path, at) ??\n      tangents[i - 1] ?? { x: 1, y: 0 };\n    points.push({ x: p.x, y: p.y });\n    tangents.push({ x: tangent.x, y: tangent.y });\n  }\n  return { length, points, tangents };\n}\n\n/** Position and unit tangent at arc length `s`, clamped to the path. */\nexport function getPathSample(samples: PathSamples, s: number) {\n  const n = samples.points.length;\n  const u = samples.length > 0 ? (s / samples.length) * (n - 1) : 0;\n  const i = Math.min(n - 2, Math.max(0, Math.floor(u)));\n  const f = Math.min(1, Math.max(0, u - i));\n  const a = samples.points[i];\n  const b = samples.points[i + 1];\n  const ta = samples.tangents[i];\n  const tb = samples.tangents[i + 1];\n  const tx = ta.x + (tb.x - ta.x) * f;\n  const ty = ta.y + (tb.y - ta.y) * f;\n  const norm = Math.hypot(tx, ty) || 1;\n  return {\n    x: a.x + (b.x - a.x) * f,\n    y: a.y + (b.y - a.y) * f,\n    tx: tx / norm,\n    ty: ty / norm,\n  };\n}\n\nexport interface PathRideStraight {\n  /** Arc length that lands on the text's left edge. */\n  start: number;\n  /** Reference x of the text's left edge on the straight baseline. */\n  left: number;\n  /** Reference y of the straight baseline. */\n  baseline: number;\n}\n\nexport interface PathRidePose {\n  x: number;\n  y: number;\n  angle: number;\n}\n\n/**\n * A point of the path pulled `k` of the way from the curve to the straight\n * baseline, lifted `lift` along its normal. Arc length maps to x one to one,\n * so the text keeps its spacing while the curve pulls taut.\n */\nexport function getPathRidePoint(\n  samples: PathSamples,\n  s: number,\n  k: number,\n  straight: PathRideStraight,\n  lift = 0,\n): PathRidePose {\n  const p = getPathSample(samples, s);\n  const x = p.x + (straight.left + (s - straight.start) - p.x) * k;\n  const y = p.y + (straight.baseline - p.y) * k;\n  const tx = p.tx + (1 - p.tx) * k;\n  const ty = p.ty * (1 - k);\n  const norm = Math.hypot(tx, ty) || 1;\n  return {\n    x: x + (ty / norm) * lift,\n    y: y - (tx / norm) * lift,\n    angle: Math.atan2(ty, tx),\n  };\n}\n\n/** Polyline of the pulled path between two arc lengths. */\nexport function getPathRideLine(\n  samples: PathSamples,\n  from: number,\n  to: number,\n  k: number,\n  straight: PathRideStraight,\n) {\n  if (to <= from) return \"\";\n  const step = samples.length / (samples.points.length - 1);\n  const stops = [from];\n  for (let s = Math.ceil(from / step) * step; s < to; s += step) {\n    if (s > from) stops.push(s);\n  }\n  stops.push(to);\n  return stops\n    .map((s, i) => {\n      const p = getPathRidePoint(samples, s, k, straight);\n      return `${i === 0 ? \"M\" : \"L\"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`;\n    })\n    .join(\"\");\n}\n\nfunction useMetrics(text: string, font: string, fallback: number) {\n  const chars = Array.from(text);\n  const key = chars.join(\"\");\n  const [metrics, setMetrics] = useState<{\n    advances: number[];\n    baseline: number;\n    capHeight: number;\n  } | null>(null);\n\n  useEffect(() => {\n    const handle = delayRender(\"path-ride: measuring the text\");\n    let released = false;\n    const release = () => {\n      if (released) return;\n      released = true;\n      continueRender(handle);\n    };\n    const measure = () => {\n      if (released) return;\n      const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n      if (ctx) {\n        ctx.font = font;\n        const m = ctx.measureText(\"H\");\n        const ascent = m.fontBoundingBoxAscent / MEASURE_SIZE;\n        const descent = m.fontBoundingBoxDescent / MEASURE_SIZE;\n        setMetrics({\n          advances: Array.from(key).map(\n            (c) => ctx.measureText(c).width / MEASURE_SIZE,\n          ),\n          baseline:\n            ascent > 0\n              ? (1 - (ascent + descent)) / 2 + ascent\n              : INTER_METRICS.baseline,\n          capHeight:\n            m.actualBoundingBoxAscent > 0\n              ? m.actualBoundingBoxAscent / MEASURE_SIZE\n              : INTER_METRICS.capHeight,\n        });\n      }\n      release();\n    };\n    document.fonts.load(font, key).then(measure, measure);\n    return release;\n  }, [key, font]);\n\n  return (\n    metrics ?? {\n      advances: chars.map(() => fallback),\n      baseline: INTER_METRICS.baseline,\n      capHeight: INTER_METRICS.capHeight,\n    }\n  );\n}\n\nexport function PathRide({\n  text = pathRideDefaults.text,\n  path = pathRideDefaults.path,\n  fontSize = pathRideDefaults.fontSize,\n  fontWeight = pathRideDefaults.fontWeight,\n  fontFamily = pathRideDefaults.fontFamily,\n  color = pathRideDefaults.color,\n  lineColor = pathRideDefaults.lineColor,\n  lineWidth = pathRideDefaults.lineWidth,\n  speed = pathRideDefaults.speed,\n  className,\n}: PathRideProps) {\n  const frame = useCurrentFrame();\n  const { fps, width, height } = useVideoConfig();\n  const unit = height / 720;\n  const t = getPathRideTime(frame, { fps, speed });\n  const metrics = useMetrics(\n    text,\n    `${fontWeight} ${MEASURE_SIZE}px ${fontFamily}`,\n    0.55,\n  );\n  const samples = useMemo(() => samplePath(path), [path]);\n  const chars = Array.from(text);\n  const advances = metrics.advances.map((a) => a * fontSize);\n  const { centers, width: textWidth } = getPathRideCenters(advances);\n  const length = samples.length;\n  const head = getPathRideHead(t, length);\n  const tail = getPathRideTail(t, length);\n  const k = getPathRideStraighten(t);\n  const lift = lineWidth / 2 + LIFT * fontSize;\n  // The line settles `lift` below the text so the capitals center on the frame.\n  const straight: PathRideStraight = {\n    start: getPathRideArc(length, 0, textWidth, fontSize),\n    left: 640 - textWidth / 2,\n    baseline: 360 + (metrics.capHeight * fontSize) / 2 + lift,\n  };\n  const tip = getPathRidePoint(samples, head, k, straight);\n  const tipOpacity = interpolate(\n    head,\n    [0, length * 0.02, length * 0.98, length],\n    [0, 1, 1, 0],\n    CLAMP,\n  );\n  // Reference space (1280×720, y down) mapped onto the composition.\n  const toX = (x: number) => width / 2 + (x - 640) * unit;\n  const toY = (y: number) => height / 2 + (y - 360) * unit;\n  const size = fontSize * unit;\n  const line = getPathRideLine(samples, tail, head, k, straight);\n\n  return (\n    <div\n      className={className}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}\n    >\n      <svg\n        width=\"100%\"\n        height=\"100%\"\n        viewBox={`0 0 ${width} ${height}`}\n        style={{ position: \"absolute\", inset: 0 }}\n      >\n        <g\n          transform={`translate(${toX(0)} ${toY(0)}) scale(${unit})`}\n          fill=\"none\"\n        >\n          {line ? (\n            <path\n              d={line}\n              stroke={lineColor}\n              strokeWidth={lineWidth}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          ) : null}\n          {tipOpacity > 0 ? (\n            <circle\n              cx={tip.x}\n              cy={tip.y}\n              r={lineWidth * 1.6}\n              fill={lineColor}\n              opacity={tipOpacity}\n            />\n          ) : null}\n        </g>\n      </svg>\n      {chars.map((char, j) => {\n        const arc = getPathRideArc(head, centers[j], textWidth, fontSize);\n        const fade = getPathRideFade(arc, fontSize);\n        if (fade <= 0) return null;\n        const pose = getPathRidePoint(samples, arc, k, straight, lift);\n        return (\n          <span\n            key={j}\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: 0,\n              fontFamily,\n              fontSize: size,\n              lineHeight: 1,\n              fontWeight,\n              fontVariationSettings: `\"wght\" ${fontWeight}`,\n              color,\n              whiteSpace: \"pre\",\n              opacity: fade,\n              transformOrigin: \"0 0\",\n              transform: `translate(${toX(pose.x)}px, ${toY(pose.y)}px) rotate(${pose.angle}rad) translate(-50%, ${-metrics.baseline * size}px)`,\n            }}\n          >\n            {char}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/path-ride.tsx"
    }
  ],
  "type": "registry:component"
}
