{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "type-repeater",
  "title": "Type Repeater",
  "description": "An After Effects style repeater on a word, where copies unfold into a tunnel, a spiral or a fan behind it, breathe, then fold back into the word.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/type-repeater/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/Inter\";\nimport { useId } from \"react\";\nimport {\n  Easing,\n  interpolate,\n  interpolateColors,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst { fontFamily: INTER } = loadFont(\"normal\", {\n  weights: [\"100\", \"900\"],\n  subsets: [\"latin\"],\n});\n\nexport type TypeRepeaterMode = \"fan\" | \"spiral\" | \"tunnel\";\n\nexport interface TypeRepeaterProps {\n  text?: string;\n  copies?: number;\n  mode?: TypeRepeaterMode;\n  fontSize?: number;\n  fontWeight?: number;\n  fontFamily?: string;\n  color?: string;\n  accent?: string;\n  outline?: boolean;\n  speed?: number;\n  className?: string;\n}\n\nexport const typeRepeaterDefaults = {\n  text: \"Repeat\",\n  copies: 12,\n  mode: \"tunnel\",\n  fontSize: 110,\n  fontWeight: 800,\n  fontFamily: INTER,\n  color: \"#fafafa\",\n  accent: \"#D97757\",\n  outline: true,\n  speed: 1,\n} satisfies Required<Omit<TypeRepeaterProps, \"className\">>;\n\n/** Beats on the 30 fps clock. */\nexport const typeRepeaterTiming = {\n  intro: [0, 10],\n  unfold: [10, 34],\n  breathe: [34, 70],\n  collapse: [70, 90],\n} as const;\n\n/** Frame where the copies have folded back into the word. */\nexport const typeRepeaterLength = typeRepeaterTiming.collapse[1];\n\n/**\n * Per-copy steps for each mode. `fan` alternates sides around a pivot below\n * the word; `spiral` and `tunnel` grow each copy outward behind it.\n */\nexport const typeRepeaterModes = {\n  fan: { rotate: 11, scale: 1, pivot: 2.6, symmetric: true },\n  spiral: { rotate: 12, scale: 1.1, pivot: 0, symmetric: false },\n  tunnel: { rotate: 0, scale: 1.2, pivot: 0, symmetric: false },\n} as const satisfies Record<\n  TypeRepeaterMode,\n  { rotate: number; scale: number; pivot: number; symmetric: boolean }\n>;\n\nconst BREATHE = 0.06;\nconst FADE = 0.85;\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst easeOutCubic = Easing.out(Easing.cubic);\nconst easeInOutCubic = Easing.inOut(Easing.cubic);\nconst unfoldEase = Easing.out(Easing.back(1.4));\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 getTypeRepeaterTime(\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/** How far the copies have spread, 0 (one word) to about 1. */\nexport function getTypeRepeaterSpread(t: number) {\n  const { unfold, breathe, collapse } = typeRepeaterTiming;\n  if (t < breathe[0]) {\n    return interpolate(t, [...unfold], [0, 1], {\n      ...CLAMP,\n      easing: unfoldEase,\n    });\n  }\n  if (t < breathe[1]) {\n    const phase = (t - breathe[0]) / (breathe[1] - breathe[0]);\n    return 1 + BREATHE * Math.sin(2 * Math.PI * phase);\n  }\n  return interpolate(t, [...collapse], [1, 0], {\n    ...CLAMP,\n    easing: easeInOutCubic,\n  });\n}\n\n/** Entrance of the front word, 0..1. */\nexport function getTypeRepeaterIntro(t: number) {\n  return interpolate(t, [...typeRepeaterTiming.intro], [0, 1], {\n    ...CLAMP,\n    easing: easeOutCubic,\n  });\n}\n\nexport interface TypeRepeaterCopy {\n  rotate: number;\n  scale: number;\n  opacity: number;\n  /** 0 for the word's color, 1 for the accent. */\n  tint: number;\n}\n\n/** Transform and fade of copy `index` (0 is the word itself). */\nexport function getTypeRepeaterCopy(\n  index: number,\n  copies: number,\n  spread: number,\n  mode: TypeRepeaterMode,\n): TypeRepeaterCopy {\n  const step = typeRepeaterModes[mode] ?? typeRepeaterModes.spiral;\n  const n = Math.max(1, Math.round(copies));\n  const steps = step.symmetric ? Math.ceil(index / 2) : index;\n  const side = step.symmetric && index % 2 === 0 ? -1 : 1;\n  const reach = steps * spread;\n  return {\n    rotate: side * step.rotate * reach || 0,\n    scale: step.scale ** reach,\n    opacity:\n      index === 0\n        ? 1\n        : (1 - (FADE * index) / n) * Math.min(1, Math.max(0, spread * 4)),\n    tint: n > 1 ? index / (n - 1) : 0,\n  };\n}\n\nexport function TypeRepeater({\n  text = typeRepeaterDefaults.text,\n  copies = typeRepeaterDefaults.copies,\n  mode = typeRepeaterDefaults.mode,\n  fontSize = typeRepeaterDefaults.fontSize,\n  fontWeight = typeRepeaterDefaults.fontWeight,\n  fontFamily = typeRepeaterDefaults.fontFamily,\n  color = typeRepeaterDefaults.color,\n  accent = typeRepeaterDefaults.accent,\n  outline = typeRepeaterDefaults.outline,\n  speed = typeRepeaterDefaults.speed,\n  className,\n}: TypeRepeaterProps) {\n  const frame = useCurrentFrame();\n  const { fps, height } = useVideoConfig();\n  const outlineId = `type-repeater-${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const unit = height / 720;\n  const size = fontSize * unit;\n  const t = getTypeRepeaterTime(frame, { fps, speed });\n  const spread = getTypeRepeaterSpread(t);\n  const intro = getTypeRepeaterIntro(t);\n  const count = Math.max(1, Math.round(finite(copies, 12)));\n  const pivot = (typeRepeaterModes[mode] ?? typeRepeaterModes.spiral).pivot;\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      }}\n    >\n      {outline ? (\n        // Outline from the filled silhouette: the variable font's overlapping\n        // contours would show through a text stroke.\n        <svg width={0} height={0} style={{ position: \"absolute\" }} aria-hidden>\n          <defs>\n            <filter id={outlineId}>\n              <feMorphology\n                in=\"SourceAlpha\"\n                operator=\"erode\"\n                radius={1.5 * unit}\n                result=\"inner\"\n              />\n              <feComposite in=\"SourceGraphic\" in2=\"inner\" operator=\"out\" />\n            </filter>\n          </defs>\n        </svg>\n      ) : null}\n      <div\n        style={{\n          position: \"relative\",\n          opacity: intro,\n          transform: `scale(${0.9 + 0.1 * intro})`,\n        }}\n      >\n        {Array.from({ length: count }, (_, i) => count - 1 - i).map((i) => {\n          const copy = getTypeRepeaterCopy(i, count, spread, mode);\n          if (copy.opacity <= 0) return null;\n          return (\n            <div\n              key={i}\n              aria-hidden={i > 0}\n              style={{\n                position: i === 0 ? \"relative\" : \"absolute\",\n                inset: i === 0 ? undefined : 0,\n                fontFamily,\n                fontSize: size,\n                lineHeight: 1,\n                fontWeight,\n                fontVariationSettings: `\"wght\" ${fontWeight}`,\n                whiteSpace: \"pre\",\n                color: interpolateColors(copy.tint, [0, 1], [color, accent]),\n                opacity: copy.opacity,\n                filter: outline && i > 0 ? `url(#${outlineId})` : undefined,\n                transformOrigin: `50% calc(50% + ${pivot}em)`,\n                transform: `rotate(${copy.rotate}deg) scale(${copy.scale})`,\n              }}\n            >\n              {text}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/type-repeater.tsx"
    }
  ],
  "type": "registry:component"
}
