{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-text",
  "title": "Ring Text",
  "description": "Text runs around a tilted 3D band that spins, reading on the near side and mirrored on the far side.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/ring-text/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { useEffect, 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 RingTextProps {\n  text?: string;\n  separator?: string;\n  radius?: number;\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  color?: string;\n  band?: string;\n  tilt?: number;\n  roll?: number;\n  period?: number;\n  speed?: number;\n  className?: string;\n}\n\nexport const ringTextDefaults = {\n  text: \"Type in motion\",\n  separator: \"•\",\n  radius: 210,\n  fontSize: 40,\n  fontWeight: 800,\n  fontFamily: INTER,\n  color: \"#fafafa\",\n  band: \"#0a0a0a\",\n  tilt: 38,\n  roll: -16,\n  period: 240,\n  speed: 1,\n} satisfies Required<Omit<RingTextProps, \"className\">>;\n\n/** Frame where the entrance has settled into the cruise spin. */\nexport const ringTextLength = 30;\n\n/** Band height, in em. */\nexport const ringBandHeight = 1.5;\n\nconst ENTRANCE = 30;\nconst FADE = 12;\nconst START_SCALE = 0.85;\n/** Extra turn, in degrees, that decays over the entrance. */\nconst ENTRANCE_SPIN = 90;\nconst WOBBLE = 6;\nconst WOBBLE_PERIOD = 150;\n/** Letters on the far side of the ring dim to this opacity. */\nconst BACK_OPACITY = 0.55;\n/** Advance per character, in em, until the face is measured. */\nconst FALLBACK_ADVANCE = 0.6;\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst easeOutCubic = Easing.out(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 getRingTextTime(\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/** The characters of one lap: the text, a space, the separator, a space. */\nexport function getRingTextToken(text: string, separator: string) {\n  return Array.from(separator ? `${text} ${separator} ` : `${text} `);\n}\n\nexport interface RingTextPanel {\n  /** Index into the token. */\n  char: number;\n  /** Panel center around the ring, radians. */\n  angle: number;\n  /** Panel width, reference px. */\n  width: number;\n}\n\nexport interface RingTextLayout {\n  radius: number;\n  repeats: number;\n  /** Extra space added to every character so the band closes, reference px. */\n  spacing: number;\n  panels: RingTextPanel[];\n}\n\n/**\n * Repeats the token as many times as fits the circumference and spreads the\n * remainder as letter spacing. A token longer than the ring grows the radius.\n */\nexport function getRingTextLayout(\n  advances: readonly number[],\n  radius: number,\n): RingTextLayout {\n  const tokenWidth = advances.reduce((sum, a) => sum + Math.max(0, a), 0);\n  const count = advances.length;\n  if (count === 0 || tokenWidth <= 0) {\n    return { radius, repeats: 0, spacing: 0, panels: [] };\n  }\n  const circumference = Math.max(2 * Math.PI * Math.max(0, radius), tokenWidth);\n  const repeats = Math.max(1, Math.floor(circumference / tokenWidth));\n  const spacing = (circumference - repeats * tokenWidth) / (repeats * count);\n  const panels: RingTextPanel[] = [];\n  let travelled = 0;\n  for (let r = 0; r < repeats; r++) {\n    for (let char = 0; char < count; char++) {\n      const width = Math.max(0, advances[char]) + spacing;\n      panels.push({\n        char,\n        angle: (2 * Math.PI * (travelled + width / 2)) / circumference,\n        width,\n      });\n      travelled += width;\n    }\n  }\n  return {\n    radius: circumference / (2 * Math.PI),\n    repeats,\n    spacing,\n    panels,\n  };\n}\n\n/** Ring pose at `t`: spin in degrees, tilt with its wobble, entrance scale and opacity. */\nexport function getRingTextPose(\n  t: number,\n  options: { period?: number; tilt?: number } = {},\n) {\n  const period = Math.max(1, finite(options.period, ringTextDefaults.period));\n  const tilt = finite(options.tilt, ringTextDefaults.tilt);\n  const settle = easeOutCubic(Math.min(1, Math.max(0, t / ENTRANCE)));\n  return {\n    spin: (360 * t) / period + ENTRANCE_SPIN * settle,\n    tilt: tilt + WOBBLE * Math.sin((2 * Math.PI * t) / WOBBLE_PERIOD),\n    scale: START_SCALE + (1 - START_SCALE) * settle,\n    opacity: interpolate(t, [0, FADE], [0, 1], CLAMP),\n  };\n}\n\n/** Letter opacity from how squarely its panel faces the camera. */\nexport function getRingTextShade(angle: number, spinDegrees: number) {\n  const facing = Math.cos(angle - (spinDegrees * Math.PI) / 180);\n  return BACK_OPACITY + ((1 - BACK_OPACITY) * (facing + 1)) / 2;\n}\n\nfunction useAdvances(chars: readonly string[], font: string, fallback: number) {\n  const key = chars.join(\"\");\n  const [advances, setAdvances] = useState<number[] | null>(null);\n\n  useEffect(() => {\n    const handle = delayRender(\"ring-text: measuring the characters\");\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        setAdvances(Array.from(key).map((c) => ctx.measureText(c).width));\n      }\n      release();\n    };\n    document.fonts.load(font, key).then(measure, measure);\n    return release;\n  }, [key, font]);\n\n  return advances ?? chars.map(() => fallback);\n}\n\nexport function RingText({\n  text = ringTextDefaults.text,\n  separator = ringTextDefaults.separator,\n  radius = ringTextDefaults.radius,\n  fontSize = ringTextDefaults.fontSize,\n  fontWeight = ringTextDefaults.fontWeight,\n  fontFamily = ringTextDefaults.fontFamily,\n  color = ringTextDefaults.color,\n  band = ringTextDefaults.band,\n  tilt = ringTextDefaults.tilt,\n  roll = ringTextDefaults.roll,\n  period = ringTextDefaults.period,\n  speed = ringTextDefaults.speed,\n  className,\n}: RingTextProps) {\n  const frame = useCurrentFrame();\n  const { fps, height } = useVideoConfig();\n  const unit = height / 720;\n  const t = getRingTextTime(frame, { fps, speed });\n  const chars = getRingTextToken(text, separator);\n  const advances = useAdvances(\n    chars,\n    `${fontWeight} ${fontSize}px ${fontFamily}`,\n    FALLBACK_ADVANCE * fontSize,\n  );\n  const layout = getRingTextLayout(advances, radius);\n  const pose = getRingTextPose(t, { period, tilt });\n  const bandHeight = ringBandHeight * fontSize * unit;\n  // Adjacent flat panels meet at an angle and their antialiased edges let the\n  // background through; bleeding each panel into its neighbors hides the seam.\n  const bleed = 1.5 * Math.max(1, unit);\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        overflow: \"hidden\",\n        perspective: 1600 * unit,\n        // Opacity lives here: on the preserve-3d ring it would flatten it.\n        opacity: pose.opacity,\n      }}\n    >\n      <div\n        style={{\n          position: \"relative\",\n          width: 0,\n          height: 0,\n          transformStyle: \"preserve-3d\",\n          // Negative tilt views the ring from above: the near side is at the\n          // bottom and reads left to right, the far side shows it mirrored.\n          transform: `scale(${pose.scale}) rotateZ(${roll}deg) rotateX(${-pose.tilt}deg) rotateY(${-pose.spin}deg)`,\n        }}\n      >\n        {layout.panels.map((panel, i) => {\n          const width = panel.width * unit;\n          return (\n            <div\n              key={i}\n              style={{\n                position: \"absolute\",\n                left: -width / 2 - bleed,\n                top: -bandHeight / 2,\n                width: width + 2 * bleed,\n                height: bandHeight,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                background: band,\n                color,\n                fontFamily,\n                fontSize: fontSize * unit,\n                lineHeight: 1,\n                fontWeight,\n                fontVariationSettings: `\"wght\" ${fontWeight}`,\n                whiteSpace: \"pre\",\n                backfaceVisibility: \"visible\",\n                transform: `rotateY(${panel.angle}rad) translateZ(${layout.radius * unit}px)`,\n              }}\n            >\n              {/* Only the letter dims: shading the band per panel would show\n                  every seam between panels. */}\n              <span\n                style={{\n                  opacity: getRingTextShade(panel.angle, pose.spin),\n                }}\n              >\n                {chars[panel.char]}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/ring-text.tsx"
    }
  ],
  "type": "registry:component"
}
