{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "font-cycle",
  "title": "Font Cycle",
  "description": "One word cycles through several typefaces and settles in its final face.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/font-cycle/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadBricolage } from \"@remotion/google-fonts/BricolageGrotesque\";\nimport { loadFont as loadFredoka } from \"@remotion/google-fonts/Fredoka\";\nimport { loadFont as loadInstrument } from \"@remotion/google-fonts/InstrumentSerif\";\nimport { loadFont as loadMono } from \"@remotion/google-fonts/JetBrainsMono\";\nimport { loadFont as loadManrope } from \"@remotion/google-fonts/Manrope\";\nimport { loadFont as loadOswald } from \"@remotion/google-fonts/Oswald\";\nimport { loadFont as loadPlayfair } from \"@remotion/google-fonts/PlayfairDisplay\";\nimport { loadFont as loadSyne } from \"@remotion/google-fonts/Syne\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst LATIN = { subsets: [\"latin\"] as [\"latin\"] };\n// Every face stays at 500 or lighter: the cycle is about letterforms, not weight.\nconst manrope = loadManrope(\"normal\", { weights: [\"500\"], ...LATIN });\nconst instrument = loadInstrument(\"normal\", { weights: [\"400\"], ...LATIN });\nconst mono = loadMono(\"normal\", { weights: [\"400\"], ...LATIN });\nconst playfair = loadPlayfair(\"italic\", { weights: [\"500\"], ...LATIN });\nconst oswald = loadOswald(\"normal\", { weights: [\"500\"], ...LATIN });\nconst fredoka = loadFredoka(\"normal\", { weights: [\"500\"], ...LATIN });\nconst bricolage = loadBricolage(\"normal\", { weights: [\"400\"], ...LATIN });\nconst syne = loadSyne(\"normal\", { weights: [\"500\"], ...LATIN });\nconst LOADS = [\n  manrope,\n  instrument,\n  mono,\n  playfair,\n  oswald,\n  fredoka,\n  bricolage,\n  syne,\n];\n\nexport interface FontCycleFace {\n  family: string;\n  label: string;\n  weight?: number;\n  style?: \"normal\" | \"italic\";\n  tracking?: number;\n}\n\nexport interface FontCycleProps {\n  word?: string;\n  fonts?: FontCycleFace[];\n  final?: FontCycleFace;\n  passes?: number;\n  showLabel?: boolean;\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst DEFAULT_FONTS: FontCycleFace[] = [\n  { family: instrument.fontFamily, label: \"Instrument Serif\", weight: 400 },\n  {\n    family: mono.fontFamily,\n    label: \"JetBrains Mono\",\n    weight: 400,\n    tracking: -0.04,\n  },\n  {\n    family: playfair.fontFamily,\n    label: \"Playfair Display Italic\",\n    weight: 500,\n    style: \"italic\",\n    tracking: -0.015,\n  },\n  { family: oswald.fontFamily, label: \"Oswald\", weight: 500 },\n  {\n    family: fredoka.fontFamily,\n    label: \"Fredoka\",\n    weight: 500,\n    tracking: -0.015,\n  },\n  {\n    family: bricolage.fontFamily,\n    label: \"Bricolage Grotesque\",\n    weight: 400,\n    tracking: -0.035,\n  },\n  { family: syne.fontFamily, label: \"Syne\", weight: 500, tracking: -0.03 },\n];\n\nconst FINAL_FACE: FontCycleFace = {\n  family: manrope.fontFamily,\n  label: \"Manrope\",\n  weight: 500,\n  tracking: -0.045,\n};\n\n// gradients.sh \"silk\" (bg #030308): indigo with a peach sheen. It only lights\n// the landed word, as a clip-text fill whose sheen drifts a little to the right.\nconst SILK = {\n  dark: [\"#8a7bff\", \"#f3d6c0\", \"#aab0ff\", \"#5d5ff0\"],\n  light: [\"#3b3fd8\", \"#f0a987\", \"#8a7bff\", \"#2a2c9e\"],\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 THEMES = {\n  dark: { bg: \"#0A0A0B\", fg: \"#F3F2EF\", grain: 0.07 },\n  light: { bg: \"#F5F4F0\", fg: \"#121214\", grain: 0.045 },\n} as const;\n\nconst PROBE = 200;\n\ninterface FaceMetric {\n  width: number;\n  cap: number;\n  inkCenter: number;\n}\n\nfunction quoted(family: string) {\n  return /[\"',]/.test(family) ? family : `\"${family}\"`;\n}\n\nfunction fontSpec(face: FontCycleFace, size: number) {\n  return `${face.style ?? \"normal\"} ${face.weight ?? 400} ${size}px ${quoted(face.family)}`;\n}\n\nfunction measure(faces: FontCycleFace[], word: string): FaceMetric[] {\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  if (!ctx) return faces.map(() => ({ width: 3, cap: 0.7, inkCenter: 1.5 }));\n  return faces.map((face) => {\n    ctx.font = fontSpec(face, PROBE);\n    ctx.letterSpacing = `${(face.tracking ?? 0) * PROBE}px`;\n    const cap = ctx.measureText(\"H\").actualBoundingBoxAscent / PROBE;\n    const m = ctx.measureText(word);\n    const left = -m.actualBoundingBoxLeft / PROBE;\n    const right = m.actualBoundingBoxRight / PROBE;\n    return {\n      width: right - left,\n      cap: cap > 0 ? cap : 0.7,\n      inkCenter: (left + right) / 2,\n    };\n  });\n}\n\nfunction useFaceMetrics(faces: FontCycleFace[], word: string) {\n  const [metrics, setMetrics] = useState<FaceMetric[] | null>(null);\n  const [handle] = useState(() => delayRender(\"font-cycle: measuring faces\"));\n  const released = useRef(false);\n  const key = faces.map((f) => fontSpec(f, 1)).join(\"|\");\n\n  useEffect(() => {\n    let live = true;\n    const release = () => {\n      if (released.current) return;\n      released.current = true;\n      continueRender(handle);\n    };\n    const specs = key.split(\"|\");\n    Promise.all([\n      ...LOADS.map((l) => l.waitUntilDone()),\n      ...specs.map((s) => document.fonts.load(s.replace(\" 1px \", \" 100px \"))),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setMetrics(measure(faces, word));\n        release();\n      });\n    return () => {\n      live = false;\n    };\n  }, [key, word, handle, faces]);\n\n  return metrics;\n}\n\nfunction Grain({ opacity }: { opacity: number }) {\n  const id = `g${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg\n      width=\"100%\"\n      height=\"100%\"\n      style={{ position: \"absolute\", inset: 0, opacity, pointerEvents: \"none\" }}\n      aria-hidden\n    >\n      <filter id={id}>\n        <feTurbulence\n          type=\"fractalNoise\"\n          baseFrequency=\"0.9\"\n          numOctaves={2}\n          seed={23}\n          stitchTiles=\"stitch\"\n        />\n        <feColorMatrix values=\"0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0 0.5  0 0 0 0.9 0\" />\n      </filter>\n      <rect width=\"100%\" height=\"100%\" filter={`url(#${id})`} />\n    </svg>\n  );\n}\n\nfunction schedule(count: number) {\n  const ends: number[] = [];\n  let at = 0;\n  for (let k = 0; k < count; k++) {\n    const intro = 9 * Math.max(0, 1 - k / 2.4) ** 2;\n    const tail = 9 * Math.max(0, (k - (count - 6)) / 5) ** 1.7;\n    at += Math.round(3 + intro + tail);\n    ends.push(at);\n  }\n  return ends;\n}\n\nexport function FontCycle({\n  word = \"Launch\",\n  fonts = DEFAULT_FONTS,\n  final = FINAL_FACE,\n  passes = 2,\n  showLabel = true,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: FontCycleProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const u = W / 1280;\n  const silkId = `fcs${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const faces = useMemo(() => [...fonts, final], [fonts, final]);\n  const metrics = useFaceMetrics(faces, word);\n\n  const sequence = useMemo(() => {\n    const list: number[] = [];\n    for (let p = 0; p < passes; p++) {\n      for (let i = 0; i < fonts.length; i++) list.push(i);\n    }\n    return list;\n  }, [fonts.length, passes]);\n  const ends = useMemo(() => schedule(sequence.length), [sequence.length]);\n  const landAt = ends[ends.length - 1] ?? 0;\n\n  if (!metrics) {\n    return <div style={{ position: \"absolute\", inset: 0, background: c.bg }} />;\n  }\n\n  const slot = ends.findIndex((e) => t < e);\n  const faceIndex = slot < 0 ? faces.length - 1 : sequence[slot];\n  const landed = slot < 0;\n  const face = faces[faceIndex];\n  const m = metrics[faceIndex];\n\n  // All faces share one cap height, so the swaps read against the guides.\n  const capPx = Math.min(\n    H * 0.26,\n    ...metrics.map((mm) => (W * 0.86 * mm.cap) / mm.width),\n  );\n  const size = capPx / m.cap;\n  const cx = W / 2;\n  const labelGap = capPx * 0.5;\n  const labelSize = Math.max(15, 18 * u);\n  const blockH = capPx + (showLabel ? labelGap + labelSize : 0);\n  const baseline = H / 2 - blockH / 2 + capPx;\n\n  const enter = interpolate(t, [0, 10], [0, 1], { ...CLAMP, easing: EASE_OUT });\n  const guideIn = interpolate(t, [0, 16], [0, 1], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const settle = interpolate(t, [landAt, landAt + 16], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  const guideOut = interpolate(t, [landAt + 4, landAt + 26], [1, 0], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const guideAlpha = guideIn * (0.12 + 0.06 * (1 - guideOut)) * guideOut;\n  const guideHalf = W * 0.42 * guideIn;\n  const lift = (1 - enter) * capPx * 0.08;\n  const scale = landed ? 1.035 - 0.035 * settle : 1;\n  const hair = Math.max(1, u);\n  const sheen = interpolate(t, [landAt, landAt + 60], [0.34, 0.48], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const silk = SILK[theme];\n  const inkL = cx - (m.width / 2) * size * 1.04;\n  const inkW = m.width * size * 1.04;\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          <linearGradient\n            id={silkId}\n            gradientUnits=\"userSpaceOnUse\"\n            x1={inkL}\n            y1={baseline - capPx * 1.3}\n            x2={inkL + inkW}\n            y2={baseline + capPx * 0.3}\n          >\n            <stop offset=\"0\" stopColor={silk[0]} />\n            <stop offset={sheen - 0.16} stopColor={silk[2]} />\n            <stop offset={sheen} stopColor={silk[1]} />\n            <stop offset={sheen + 0.2} stopColor={silk[2]} />\n            <stop offset=\"1\" stopColor={silk[3]} />\n          </linearGradient>\n        </defs>\n        {[baseline, baseline - capPx].map((y) => (\n          <rect\n            key={y}\n            x={cx - guideHalf}\n            y={y - hair / 2}\n            width={guideHalf * 2}\n            height={hair}\n            fill={c.fg}\n            opacity={guideAlpha}\n          />\n        ))}\n        <g\n          transform={`translate(${cx} ${baseline - capPx / 2 + lift}) scale(${scale}) translate(${-cx} ${-(baseline - capPx / 2)})`}\n          opacity={enter}\n        >\n          <text\n            x={cx - m.inkCenter * size}\n            y={baseline}\n            fontFamily={face.family}\n            fontWeight={face.weight ?? 400}\n            fontStyle={face.style ?? \"normal\"}\n            fontSize={size}\n            letterSpacing={`${face.tracking ?? 0}em`}\n            fill={landed ? `url(#${silkId})` : c.fg}\n          >\n            {word}\n          </text>\n        </g>\n      </svg>\n      {showLabel ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            top: baseline + labelGap,\n            display: \"flex\",\n            justifyContent: \"center\",\n            alignItems: \"center\",\n            gap: labelSize * 0.6,\n            fontFamily: mono.fontFamily,\n            fontWeight: 400,\n            fontSize: labelSize,\n            lineHeight: 1,\n            letterSpacing: \"0.02em\",\n            textTransform: \"uppercase\",\n            color: c.fg,\n            opacity: enter * (landed ? 0.62 + 0.2 * settle : 0.46),\n          }}\n        >\n          <span\n            style={{\n              width: labelSize * 0.42,\n              height: labelSize * 0.42,\n              borderRadius: \"50%\",\n              background: landed ? accent : c.fg,\n              opacity: landed ? 1 : 0.5,\n            }}\n          />\n          <span>\n            {face.label}\n            {face.weight ? ` ${face.weight}` : \"\"}\n          </span>\n        </div>\n      ) : null}\n      <Grain opacity={c.grain} />\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/font-cycle.tsx"
    }
  ],
  "type": "registry:component"
}
