{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "case-flip",
  "title": "Case Flip",
  "description": "A headline flips letter by letter from lower to upper and back to its final casing.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/case-flip/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont } from \"@remotion/google-fonts/BricolageGrotesque\";\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 face = loadFont(\"normal\", { weights: [\"500\"], subsets: [\"latin\"] });\n\nexport interface CaseFlipProps {\n  /** The headline in its final casing. */\n  text?: string;\n  /** Word (as written in `text`) that lands in the accent color. */\n  accentWord?: string;\n  /** Frames between neighbouring glyphs in each wave. */\n  stagger?: number;\n  showGuides?: boolean;\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// gradients.sh \"halo\" (bg #030306): warm peach through rose into blue. It\n// fills the accent word once it lands, left to right, and its stops slide a\n// few percent while the word holds. Light drops the palest stop.\nconst HALO = {\n  dark: [\"#ffb36b\", \"#e2457a\", \"#3d4dff\"],\n  light: [\"#f08a3a\", \"#d6336c\", \"#3d4dff\"],\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 EASE_IN = Easing.bezier(0.55, 0, 0.9, 0.4);\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst WEIGHT = 500;\nconst PROBE = 200;\nconst TRACK_LOWER = -0.035;\nconst TRACK_UPPER = -0.01;\nconst FLIP = 10;\n\n/** Per-slot glyph forms for the three casing stages: lower, UPPER, final. */\ntype Forms = [string, string, string];\n\ninterface Metrics {\n  /** adv[i][own stage][next stage], in em, kerned against the next glyph. */\n  adv: number[][][];\n  /** Unkerned advance of each stage's glyph, in em. */\n  plain: number[][];\n  xHeight: number;\n  capHeight: number;\n}\n\nfunction buildForms(text: string): Forms[] {\n  return [...text].map((ch) => [ch.toLowerCase(), ch.toUpperCase(), ch]);\n}\n\nfunction isUpperLetter(ch: string) {\n  return ch !== ch.toLowerCase() && ch === ch.toUpperCase();\n}\n\nfunction measure(forms: Forms[]): Metrics {\n  const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n  const fallback = forms.map((f) =>\n    [0, 1, 2].map((s) => [0, 1, 2].map(() => (f[s] === \" \" ? 0.26 : 0.6))),\n  );\n  if (!ctx) {\n    return {\n      adv: fallback,\n      plain: fallback.map((a) => a.map((row) => row[0])),\n      xHeight: 0.55,\n      capHeight: 0.73,\n    };\n  }\n  ctx.font = `${WEIGHT} ${PROBE}px \"${face.fontFamily}\"`;\n  const w = (s: string) => ctx.measureText(s).width / PROBE;\n  const adv = forms.map((f, i) => {\n    const next = forms[i + 1];\n    return [0, 1, 2].map((s) => {\n      const own = f[s];\n      const track = isUpperLetter(own) ? TRACK_UPPER : TRACK_LOWER;\n      return [0, 1, 2].map((n) => {\n        const kerned = next ? w(own + next[n]) - w(next[n]) : w(own);\n        return kerned + (own === \" \" ? 0 : track);\n      });\n    });\n  });\n  const plain = forms.map((f) => f.map((g) => w(g)));\n  const xHeight = ctx.measureText(\"x\").actualBoundingBoxAscent / PROBE;\n  const capHeight = ctx.measureText(\"H\").actualBoundingBoxAscent / PROBE;\n  return {\n    adv,\n    plain,\n    xHeight: xHeight > 0 ? xHeight : 0.55,\n    capHeight: capHeight > 0 ? capHeight : 0.73,\n  };\n}\n\nfunction useMetrics(forms: Forms[]) {\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  const [handle] = useState(() => delayRender(\"case-flip: measuring glyphs\"));\n  const released = useRef(false);\n\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      face.waitUntilDone(),\n      document.fonts.load(`${WEIGHT} 100px \"${face.fontFamily}\"`),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setMetrics(measure(forms));\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [forms, handle]);\n\n  return metrics;\n}\n\nfunction lerp(a: number, b: number, t: number) {\n  return a + (b - a) * t;\n}\n\n/** Sample a 3×3 table at fractional (stage, nextStage), bilinear. */\nfunction sample(table: number[][], s: number, n: number) {\n  const s0 = Math.min(1, Math.floor(s));\n  const n0 = Math.min(1, Math.floor(n));\n  const fs = s - s0;\n  const fn = n - n0;\n  const top = lerp(table[s0][n0], table[s0][n0 + 1], fn);\n  const bottom = lerp(table[s0 + 1][n0], table[s0 + 1][n0 + 1], fn);\n  return lerp(top, bottom, fs);\n}\n\nfunction mix(a: string, b: string, t: number) {\n  const pa = Number.parseInt(a.slice(1), 16);\n  const pb = Number.parseInt(b.slice(1), 16);\n  const ch = (shift: number) =>\n    Math.round(lerp((pa >> shift) & 255, (pb >> shift) & 255, t));\n  return `rgb(${ch(16)}, ${ch(8)}, ${ch(0)})`;\n}\n\nfunction wordIndexOf(text: string, word: string) {\n  if (!word) return [-1, -1];\n  const at = text.indexOf(word);\n  return at < 0 ? [-1, -1] : [at, at + word.length];\n}\n\nexport function CaseFlip({\n  text = \"Launch it LOUD\",\n  accentWord = \"LOUD\",\n  stagger = 2,\n  showGuides = true,\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: CaseFlipProps) {\n  const t = useCurrentFrame() * speed;\n  const { width: W, height: H } = useVideoConfig();\n  const c = THEMES[theme];\n  const u = W / 1280;\n  const haloId = `cfh${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const forms = useMemo(() => buildForms(text), [text]);\n  const metrics = useMetrics(forms);\n  const [accentFrom, accentTo] = wordIndexOf(text, accentWord);\n\n  if (!metrics)\n    return <div style={{ position: \"absolute\", inset: 0, background: c.bg }} />;\n\n  const n = forms.length;\n  const wave1 = 16;\n  const wave2 = wave1 + (n - 1) * stagger + FLIP + 12;\n  const settled = wave2 + (n - 1) * stagger + FLIP;\n\n  // Stage of each slot (0 lower → 1 UPPER → 2 final) and its flip clocks.\n  const local = forms.map((_, i) => {\n    const q1 = interpolate(\n      t,\n      [wave1 + i * stagger, wave1 + i * stagger + FLIP],\n      [0, 1],\n      CLAMP,\n    );\n    const q2 = interpolate(\n      t,\n      [wave2 + i * stagger, wave2 + i * stagger + FLIP],\n      [0, 1],\n      CLAMP,\n    );\n    return { q1, q2 };\n  });\n  const stage = local.map(({ q1, q2 }) => EASE_IO(q1) + EASE_IO(q2));\n\n  const widest = Math.max(\n    ...[0, 1, 2].map((s) =>\n      metrics.adv.reduce((sum, a, i) => sum + a[s][i < n - 1 ? s : 0], 0),\n    ),\n  );\n  const size = Math.min(H * 0.3, (W * 0.92) / widest);\n  const cap = metrics.capHeight * size;\n  const xh = metrics.xHeight * size;\n  const baseline = H / 2 + cap / 2;\n\n  const advances = forms.map((_, i) =>\n    sample(metrics.adv[i], stage[i], i < n - 1 ? stage[i + 1] : 0),\n  );\n  const lineW = advances.reduce((a, b) => a + b, 0) * size;\n  let cursor = W / 2 - lineW / 2;\n\n  const enter = interpolate(t, [0, 14], [0, 1], { ...CLAMP, easing: EASE_OUT });\n  const guidesIn = interpolate(t, [2, 22], [0, 1], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const guidesOut = interpolate(t, [settled, settled + 20], [1, 0], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const guideHalf = (lineW / 2 + size * 0.5) * guidesIn;\n  const hair = Math.max(1, u);\n\n  const lineL = cursor;\n  const accentL =\n    lineL +\n    advances.slice(0, Math.max(0, accentFrom)).reduce((a, b) => a + b, 0) *\n      size;\n  const accentW =\n    advances\n      .slice(Math.max(0, accentFrom), Math.max(0, accentTo))\n      .reduce((a, b) => a + b, 0) * size;\n  const drift = interpolate(t, [settled, settled + 50], [0, 0.08], {\n    ...CLAMP,\n    easing: EASE_IO,\n  });\n  const halo = HALO[theme];\n\n  const glyphs = forms.map((f, i) => {\n    const x = cursor;\n    const slot = advances[i] * size;\n    cursor += slot;\n    if (f[0] === \" \") return null;\n\n    const { q1, q2 } = local[i];\n    const flipping2 = q2 > 0;\n    const q = flipping2 ? q2 : q1;\n    const from = flipping2 ? f[1] : f[0];\n    const to = flipping2 ? f[2] : f[1];\n    const changes = from !== to;\n    const firstHalf = q < 0.5;\n    const shown = changes && firstHalf ? from : to;\n    const shownStage = flipping2 ? (firstHalf ? 1 : 2) : firstHalf ? 0 : 1;\n    const nextStage = i < n - 1 ? stage[i + 1] : 0;\n    const ownSlot = sample(metrics.adv[i], shownStage, nextStage) * size;\n    const glyphW = metrics.plain[i][shownStage] * size;\n\n    // Squash onto the baseline, swap the glyph, spring back up.\n    let sy = 1;\n    let sx = 1;\n    if (q > 0 && q < 1) {\n      if (changes) {\n        if (firstHalf) {\n          const k = EASE_IN(q / 0.5);\n          sy = lerp(1, 0.06, k);\n          sx = lerp(1, 1.1, k);\n        } else {\n          const k = (q - 0.5) / 0.5;\n          sy = interpolate(k, [0, 0.65, 1], [0.06, 1.045, 1], {\n            ...CLAMP,\n            easing: EASE_OUT,\n          });\n          sx = interpolate(k, [0, 0.65, 1], [1.1, 0.985, 1], {\n            ...CLAMP,\n            easing: EASE_OUT,\n          });\n        }\n      } else {\n        const dip = Math.sin(Math.PI * q);\n        sy = 1 - 0.1 * dip;\n        sx = 1 + 0.03 * dip;\n      }\n    }\n\n    const inAccent = i >= accentFrom && i < accentTo;\n    // The wave front carries a brief accent tint; the accent word then takes\n    // the halo gradient, faded in over its second flip.\n    const pulse = changes ? Math.sin(Math.PI * q) * 0.55 : 0;\n    const fill = pulse > 0 ? mix(c.fg, accent, pulse) : c.fg;\n    const lit = inAccent ? EASE_OUT(q2) : 0;\n    const cx = x + (slot - ownSlot) / 2 + glyphW / 2;\n    const glyph = (paint: string, opacity: number, key: string) => (\n      <text\n        key={key}\n        x={cx}\n        y={baseline}\n        textAnchor=\"middle\"\n        fontFamily={face.fontFamily}\n        fontWeight={WEIGHT}\n        fontSize={size}\n        fill={paint}\n        opacity={opacity}\n        // Scale about the glyph's baseline point; at rest the transform is the\n        // identity, so the shared gradient lines up across the whole word.\n        transform={`translate(${cx} ${baseline}) scale(${sx} ${sy}) translate(${-cx} ${-baseline})`}\n      >\n        {shown}\n      </text>\n    );\n\n    return (\n      <g key={`${i}-${f[2]}`}>\n        {lit < 1 ? glyph(fill, 1, \"base\") : null}\n        {lit > 0 ? glyph(`url(#${haloId})`, lit, \"halo\") : null}\n      </g>\n    );\n  });\n\n  const guideYs = [baseline, baseline - xh, baseline - cap];\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={haloId}\n            gradientUnits=\"userSpaceOnUse\"\n            x1={accentL - accentW * 0.05}\n            y1={baseline - cap}\n            x2={accentL + accentW * 1.05}\n            y2={baseline}\n          >\n            <stop offset={0.04 - drift} stopColor={halo[0]} />\n            <stop offset={0.5 - drift} stopColor={halo[1]} />\n            <stop offset={1 - drift} stopColor={halo[2]} />\n          </linearGradient>\n        </defs>\n        {showGuides\n          ? guideYs.map((y, k) => (\n              <rect\n                key={y}\n                x={W / 2 - guideHalf}\n                y={y - hair / 2}\n                width={guideHalf * 2}\n                height={hair}\n                fill={c.fg}\n                opacity={guidesIn * (k === 0 ? 0.16 : 0.1) * guidesOut}\n              />\n            ))\n          : null}\n        <g\n          opacity={enter}\n          transform={`translate(0 ${(1 - enter) * cap * 0.12})`}\n        >\n          {glyphs}\n        </g>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/case-flip.tsx"
    }
  ],
  "type": "registry:component"
}
