{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "font-shuffle",
  "title": "Font Shuffle",
  "description": "Launch title animation: the line decodes out of scrambled glyphs set in the wrong typeface, flashes the accent, settles to ink, then the product name cycles through eleven typefaces (mono, light, humanist, pixel, script, slab, condensed, italic, blackletter, wide, bold grotesk) while the line zooms a step closer on each swap. Every beat is a held state measured off a recording frame by frame — timing, masks, layout, zoom and colour.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/font-shuffle/index.tsx",
      "content": "// Font Shuffle · snapcn — https://snapcn.dev/docs/text/font-shuffle\n\"use client\";\n\nimport {\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  useCurrentFrame,\n} from \"remotion\";\nimport { type SnapCnTheme, useSnapCnTheme } from \"@/lib/snap-cn-ui\";\n\n/**\n * A launch title that decodes and then can't decide what it's wearing.\n * Scrambled glyphs from the wrong typeface flicker into place, the line\n * lands in the accent, settles to ink and holds, then the second half\n * cycles through a run of typefaces while the whole line steps closer.\n *\n * ## Everything here was measured off a recording, frame by frame\n *\n * A 2.03s, 638 × 358 screen capture (68 frames, 60fps then 30fps) was\n * decoded in BT.709 and every state was fitted by rendering candidate type in\n * Chrome, box-downsampling from 4×, blurring by the capture's own σ 0.65px\n * (measured on the pixel face's square edges) and least-squaring against it:\n *\n *   - the capture is a sequence of HELD states, not tweens. Every change lands\n *     on a 30fps frame boundary, and between changes consecutive frames\n *     differ only by codec noise. `BEATS` below is that timeline.\n *   - the decode is the real line set in a Baskerville, with a per-character\n *     visibility mask and oversized foreign glyphs (`b`, `e`, `a` …) that\n *     persist from one beat to the next. Masks and extras were read glyph by\n *     glyph and re-fitted for position, size and weight.\n *   - the purple is one colour, `#9b8ed3`, measured on both purple beats.\n *   - the line is centred (x ≈ 317.5 on every beat, the capture is 1.5px off\n *     centre) and every font swap is also a zoom about the frame's middle:\n *     ×1.028, 1.086, 1.111, 1.152, 1.249 on the serif's own size.\n *\n * ## The type is the closest free match, not the original\n *\n * The recording's faces are not on Google Fonts. Each was fingerprinted\n * against every Google family in its category and the best free match taken:\n * Source Serif 4 (opsz 16) for the line, Libre Baskerville for the decode,\n * Fragment Mono, Hind, Coral Pixels and Mona Sans for the swaps. Geometry and\n * timing are the recording's; glyph shapes are as close as free type gets.\n * The six faces between the pixel and the grotesk are an extension, not\n * measured: Pacifico, Zilla Slab, Bebas Neue, Playfair Display italic,\n * UnifrakturMaguntia and Unbounded.\n */\n\n/* ─────────────────────────────────────────────────────────────────────────\n   The scene, in the reference's own 638 × 358 pixels and 30fps frames\n   ───────────────────────────────────────────────────────────────────────── */\n\nexport const REF_W = 638;\nexport const REF_H = 358;\n\nconst CDN = \"https://cdn.jsdelivr.net/npm\";\n\n/** Every face the scene draws in, loaded as one `FontFace` each. */\nexport const FACES = {\n  serif: {\n    family: \"Font Shuffle Serif\",\n    src: `${CDN}/@fontsource-variable/source-serif-4@5.3.0/files/source-serif-4-latin-opsz-normal.woff2`,\n    weight: \"200 900\",\n    settings: \"\\\"opsz\\\" 16\",\n  },\n  glitch: {\n    family: \"Font Shuffle Glitch\",\n    src: `${CDN}/@fontsource-variable/libre-baskerville@5.3.0/files/libre-baskerville-latin-wght-normal.woff2`,\n    weight: \"400 700\",\n  },\n  mono: {\n    family: \"Font Shuffle Mono\",\n    src: `${CDN}/@fontsource-variable/source-code-pro@5.3.0/files/source-code-pro-latin-wght-normal.woff2`,\n    weight: \"200 900\",\n  },\n  light: {\n    family: \"Font Shuffle Light\",\n    src: `${CDN}/@fontsource-variable/inter@5.3.0/files/inter-latin-wght-normal.woff2`,\n    weight: \"100 900\",\n  },\n  humanist: {\n    family: \"Font Shuffle Humanist\",\n    src: `${CDN}/@fontsource/hind@5.3.0/files/hind-latin-400-normal.woff2`,\n    weight: \"400\",\n  },\n  pixel: {\n    family: \"Font Shuffle Pixel\",\n    src: `${CDN}/@fontsource/vt323@5.3.0/files/vt323-latin-400-normal.woff2`,\n    weight: \"400\",\n  },\n  grotesk: {\n    family: \"Font Shuffle Grotesk\",\n    src: `${CDN}/@fontsource-variable/mona-sans@5.3.0/files/mona-sans-latin-wght-normal.woff2`,\n    weight: \"200 900\",\n  },\n  // The run added after the recording's five: script, slab, condensed, italic,\n  // blackletter and wide. Each one is as far from its neighbours as type gets.\n  script: {\n    family: \"Font Shuffle Script\",\n    src: `${CDN}/@fontsource/pacifico@5.2.5/files/pacifico-latin-400-normal.woff2`,\n    weight: \"400\",\n  },\n  slab: {\n    family: \"Font Shuffle Slab\",\n    src: `${CDN}/@fontsource/zilla-slab@5.2.5/files/zilla-slab-latin-600-normal.woff2`,\n    weight: \"600\",\n  },\n  condensed: {\n    family: \"Font Shuffle Condensed\",\n    src: `${CDN}/@fontsource/bebas-neue@5.2.5/files/bebas-neue-latin-400-normal.woff2`,\n    weight: \"400\",\n  },\n  italic: {\n    family: \"Font Shuffle Italic\",\n    src: `${CDN}/@fontsource-variable/playfair-display@5.2.5/files/playfair-display-latin-wght-italic.woff2`,\n    weight: \"400 900\",\n  },\n  blackletter: {\n    family: \"Font Shuffle Blackletter\",\n    src: `${CDN}/@fontsource/unifrakturmaguntia@5.2.5/files/unifrakturmaguntia-latin-400-normal.woff2`,\n    weight: \"400\",\n  },\n  wide: {\n    family: \"Font Shuffle Wide\",\n    src: `${CDN}/@fontsource-variable/unbounded@5.2.5/files/unbounded-latin-wght-normal.woff2`,\n    weight: \"200 900\",\n  },\n} as const;\n\nexport type FaceKey = keyof typeof FACES;\n\n/** One run of type: face, size and weight in reference px, tracking, word gap. */\nexport interface Run {\n  face: FaceKey;\n  size: number;\n  weight: number;\n  /** letter-spacing, px */\n  track: number;\n  /** word-spacing added to the face's own space, px */\n  space?: number;\n}\n\n/** A foreign glyph laid over the decode, anchored to a character of the line. */\nexport interface Extra {\n  ch: string;\n  /** Index into the reference line it sits on. */\n  at: number;\n  /** Offset from that character's start, px. */\n  dx: number;\n  base: number;\n  size: number;\n  weight: number;\n}\n\nexport type Beat =\n  | {\n      kind: \"glitch\";\n      run: Run;\n      /** Centre of the line's advance box. */\n      cx: number;\n      base: number;\n      /** Indices of the reference line that are drawn. */\n      show: number[];\n      extras: Extra[];\n    }\n  | {\n      kind: \"pair\";\n      lead: Run;\n      subject: Run;\n      leadBase: number;\n      subjectBase: number;\n      /** Between the end of the lead's advance and the subject's start. */\n      gap: number;\n      cx: number;\n      accent: boolean;\n      /** Indices of `lead + \" \" + subject` that are held back (drawn clear). */\n      hide?: number[];\n    };\n\n/** The reference line the masks and anchors were read on: 24 characters. */\nconst REF_LEN = 24;\n\n/** The frame each beat starts on (30fps), and the beat. Read off the capture. */\nexport const BEATS: [number, Beat][] = [\n  [\n    0,\n    {\n      kind: \"glitch\",\n      run: { face: \"glitch\", size: 28.25, weight: 416, track: -0.94 },\n      cx: 317.597,\n      base: 186.43,\n      show: [2, 4, 7, 10, 14, 17, 19, 22],\n      extras: [\n        { ch: \"i\", at: 1, dx: -2.643, base: 189.25, size: 33.95, weight: 700 },\n        { ch: \"l\", at: 1, dx: 7.527, base: 189.55, size: 35.55, weight: 691 },\n        { ch: \"e\", at: 5, dx: 4.176, base: 189.59, size: 36.56, weight: 670 },\n        { ch: \"b\", at: 11, dx: -1.19, base: 189.73, size: 36.46, weight: 400 },\n        { ch: \"a\", at: 20, dx: -3.586, base: 190.12, size: 35.92, weight: 641 },\n        { ch: \"e\", at: 23, dx: 22.728, base: 189.5, size: 36.53, weight: 673 },\n      ],\n    },\n  ],\n  [\n    2,\n    {\n      kind: \"glitch\",\n      run: { face: \"glitch\", size: 28.8, weight: 401, track: -0.12 },\n      cx: 317.722,\n      base: 186.78,\n      show: [0, 1, 2, 4, 7, 9, 10, 13, 14, 15, 17, 19, 22, 23],\n      extras: [\n        { ch: \"b\", at: 11, dx: -1.886, base: 191.01, size: 40.27, weight: 523 },\n        { ch: \"e\", at: 23, dx: 26.711, base: 190.38, size: 39.71, weight: 635 },\n      ],\n    },\n  ],\n  [\n    4,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 38.31, weight: 489, track: -1.144 },\n      subject: { face: \"serif\", size: 38.31, weight: 489, track: -1.144 },\n      leadBase: 188.26,\n      subjectBase: 188.26,\n      gap: 7.719,\n      cx: 317.543,\n      accent: true,\n      hide: [3, 6, 16],\n    },\n  ],\n  [\n    6,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 41.98, weight: 502, track: -1.325 },\n      subject: { face: \"serif\", size: 41.98, weight: 502, track: -1.325 },\n      leadBase: 189.35,\n      subjectBase: 189.35,\n      gap: 8.375,\n      cx: 317.475,\n      accent: true,\n    },\n  ],\n  [\n    7,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 42, weight: 573, track: -1.4 },\n      subject: { face: \"serif\", size: 42, weight: 573, track: -1.4, space: 1.263 },\n      leadBase: 189.29,\n      subjectBase: 189.29,\n      gap: 9.951,\n      cx: 317.943,\n      accent: false,\n    },\n  ],\n  [\n    32,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 43.48, weight: 511, track: -1.273 },\n      subject: { face: \"mono\", size: 39.96, weight: 354, track: -2.741, space: 0.765 },\n      leadBase: 189.603,\n      subjectBase: 189.296,\n      gap: 6.927,\n      cx: 317.275,\n      accent: false,\n    },\n  ],\n  [\n    35,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 45.898, weight: 546, track: -1.673 },\n      subject: { face: \"light\", size: 42.488, weight: 392, track: -0.819, space: 5.88 },\n      leadBase: 190.225,\n      subjectBase: 189.492,\n      gap: 11.558,\n      cx: 316.424,\n      accent: false,\n    },\n  ],\n  [\n    39,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 46.968, weight: 509, track: -1.21 },\n      subject: { face: \"humanist\", size: 46.707, weight: 400, track: 0.936, space: -0.358 },\n      leadBase: 190.563,\n      subjectBase: 190.696,\n      gap: 12.875,\n      cx: 315.741,\n      accent: false,\n    },\n  ],\n  [\n    43,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 48.696, weight: 518, track: -1.197 },\n      subject: { face: \"pixel\", size: 67.469, weight: 400, track: -2.468, space: -4.337 },\n      leadBase: 190.919,\n      subjectBase: 191.087,\n      gap: 5.67,\n      cx: 316.477,\n      accent: false,\n    },\n  ],\n  [\n    47,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 49.285, weight: 524, track: -1.26 },\n      subject: { face: \"script\", size: 45.342, weight: 400, track: 0.0 },\n      leadBase: 191.071,\n      subjectBase: 191.071,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    51,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 49.875, weight: 530, track: -1.32 },\n      subject: { face: \"slab\", size: 53.865, weight: 600, track: -0.8 },\n      leadBase: 191.223,\n      subjectBase: 191.223,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    54,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 50.464, weight: 536, track: -1.38 },\n      subject: { face: \"condensed\", size: 60.557, weight: 400, track: 0.6 },\n      leadBase: 191.375,\n      subjectBase: 191.375,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    58,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 51.054, weight: 541, track: -1.44 },\n      subject: { face: \"italic\", size: 53.096, weight: 560, track: -0.8 },\n      leadBase: 191.528,\n      subjectBase: 191.528,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    61,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 51.643, weight: 547, track: -1.5 },\n      subject: { face: \"blackletter\", size: 59.389, weight: 400, track: 0.0 },\n      leadBase: 191.68,\n      subjectBase: 191.68,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    65,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 52.233, weight: 553, track: -1.56 },\n      subject: { face: \"wide\", size: 41.786, weight: 520, track: -1.4 },\n      leadBase: 191.832,\n      subjectBase: 191.832,\n      gap: 11,\n      cx: 316.4,\n      accent: false,\n    },\n  ],\n  [\n    69,\n    {\n      kind: \"pair\",\n      lead: { face: \"serif\", size: 52.822, weight: 559, track: -1.594 },\n      subject: { face: \"grotesk\", size: 51.994, weight: 575, track: -1.689, space: 0.327 },\n      leadBase: 191.984,\n      subjectBase: 192.782,\n      gap: 11.638,\n      cx: 316.37,\n      accent: false,\n    },\n  ],\n];\n\n/**\n * Frames 0–43 are the recording's, with its 1.2s hold before the first swap\n * cut to 0.8s; 47–65 are six more faces at the same three-to-four-frame\n * cadence and the same zoom per step (lead size and baseline interpolated\n * between the pixel and grotesk beats). The grotesk is still the landing,\n * held 1.5s.\n */\nexport const SCENE_FRAMES = 115;\n\n/**\n * A reference index for character `i` of an `n`-character line. The masks and\n * anchors were read on the 24-character original; a longer or shorter line\n * samples them proportionally, so the decode keeps its density and rhythm.\n */\nfunction refIndex(i: number, n: number): number {\n  return n <= 1 ? 0 : Math.round((i * (REF_LEN - 1)) / (n - 1));\n}\nfunction ownIndex(r: number, n: number): number {\n  return n <= 1 ? 0 : Math.round((r * (n - 1)) / (REF_LEN - 1));\n}\n\n/* ─────────────────────────────────────────────────────────────────────────\n   Fonts and measurement\n   ───────────────────────────────────────────────────────────────────────── */\n\nlet facesLoad: Promise<unknown> | null = null;\nfunction loadFaces(): Promise<unknown> {\n  if (facesLoad) return facesLoad;\n  if (typeof FontFace === \"undefined\") {\n    facesLoad = Promise.resolve();\n    return facesLoad;\n  }\n  facesLoad = Promise.all(\n    // `keys().map`, not `Object.values` (ES2017): stock Remotion types ES2015.\n    (Object.keys(FACES) as (keyof typeof FACES)[])\n      .map((k) => FACES[k])\n      .filter((f) => f.src)\n      .map((f) => {\n        const face = new FontFace(f.family, `url(${f.src}) format(\"woff2\")`, {\n          weight: f.weight,\n        });\n        document.fonts.add(face);\n        // A face that fails to load falls back to the next in the stack —\n        // the scene still plays, in the wrong clothes.\n        return face.load().catch(() => undefined);\n      }),\n  );\n  return facesLoad;\n}\n\nfunction runStyle(run: Run): CSSProperties {\n  const face = FACES[run.face];\n  return {\n    fontFamily: `\"${face.family}\", serif`,\n    fontSize: run.size,\n    fontWeight: run.weight,\n    letterSpacing: run.track,\n    wordSpacing: run.space ?? 0,\n    fontVariationSettings: \"settings\" in face ? face.settings : undefined,\n    whiteSpace: \"pre\",\n    // Unhinted outlines: a held state that re-snaps its stems at another\n    // render size is a different picture.\n    textRendering: \"geometricPrecision\",\n  };\n}\n\ninterface Measured {\n  /** Per beat: [lead width, subject width] or [line width, char starts…]. */\n  widths: number[][];\n}\n\n/**\n * Every width the layout needs, read once off hidden SVG text in the scene's\n * own units. Canvas cannot pin `opsz`, so it would measure a different cut.\n */\nfunction useMeasure(\n  lead: string,\n  subject: string,\n): [Measured | null, RefObject<SVGGElement | null>] {\n  const probe = useRef<SVGGElement>(null);\n  const [m, setM] = useState<Measured | null>(null);\n  const [handle] = useState(() => delayRender(\"font-shuffle: measure\"));\n  useLayoutEffect(() => {\n    let live = true;\n    loadFaces()\n      .then(() => document.fonts.ready)\n      .then(() => {\n        if (!live || !probe.current) return;\n        const groups = probe.current.querySelectorAll<SVGGElement>(\"g[data-beat]\");\n        const widths = Array.from(groups, (g) => {\n          const texts = g.querySelectorAll(\"text\");\n          if (g.dataset.beat === \"glitch\") {\n            const t = texts[0]!;\n            const n = t.getNumberOfChars();\n            const starts: number[] = [];\n            for (let i = 0; i < n; i++) starts.push(t.getStartPositionOfChar(i).x);\n            return [t.getComputedTextLength(), ...starts];\n          }\n          return Array.from(texts, (t) => t.getComputedTextLength());\n        });\n        setM({ widths });\n        continueRender(handle);\n      });\n    return () => {\n      live = false;\n    };\n  }, [lead, subject, handle]);\n  return [m, probe];\n}\n\n/* ─────────────────────────────────────────────────────────────────────────\n   The component\n   ───────────────────────────────────────────────────────────────────────── */\n\nexport interface FontShuffleProps {\n  /** The fixed half of the line, set in the serif throughout. */\n  lead?: string;\n  /** The half that tries on typefaces. */\n  subject?: string;\n  /** The colour the line lands in before it settles to ink. */\n  accent?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\nexport function FontShuffle({\n  lead = \"Introducing\",\n  subject = \"snapcn Pro\",\n  accent = \"#9b8ed3\",\n  theme,\n  mode = \"light\",\n}: FontShuffleProps) {\n  const frame = useCurrentFrame();\n  const t = useSnapCnTheme(theme, mode);\n  const line = `${lead} ${subject}`;\n  const n = line.length;\n  const [m, probe] = useMeasure(lead, subject);\n\n  // The last beat already reached. A loop, not `findLastIndex` (ES2023).\n  let index = -1;\n  for (let i = BEATS.length - 1; i >= 0; i--) {\n    if (frame >= BEATS[i]![0]) {\n      index = i;\n      break;\n    }\n  }\n  const beat = BEATS[Math.max(0, index)]![1];\n  const w = m?.widths[Math.max(0, index)];\n\n  let content: ReactNode = null;\n  if (w && beat.kind === \"glitch\") {\n    const [width, ...starts] = w;\n    const x0 = beat.cx - width! / 2;\n    const shown = new Set(beat.show);\n    content = (\n      <g fill={t.foreground}>\n        <text x={x0} y={beat.base} style={runStyle(beat.run)}>\n          {[...line].map((ch, i) => (\n            <tspan key={i} fillOpacity={shown.has(refIndex(i, n)) ? 1 : 0}>\n              {ch}\n            </tspan>\n          ))}\n        </text>\n        {beat.extras.map((e, k) => {\n          const at = Math.min(n - 1, ownIndex(e.at, n));\n          return (\n            <text\n              key={k}\n              x={x0 + (starts[at] ?? 0) + e.dx}\n              y={e.base}\n              style={runStyle({ ...beat.run, size: e.size, weight: e.weight, track: 0 })}\n            >\n              {e.ch}\n            </text>\n          );\n        })}\n      </g>\n    );\n  } else if (w && beat.kind === \"pair\") {\n    const [wl = 0, ws = 0] = w;\n    const x0 = beat.cx - (wl + beat.gap + ws) / 2;\n    const hidden = new Set(beat.hide ?? []);\n    const chars = (text: string, offset: number) =>\n      [...text].map((ch, i) => (\n        <tspan\n          key={i}\n          fillOpacity={hidden.has(refIndex(offset + i, n)) ? 0 : 1}\n        >\n          {ch}\n        </tspan>\n      ));\n    content = (\n      <g fill={beat.accent ? accent : t.foreground}>\n        <text x={x0} y={beat.leadBase} style={runStyle(beat.lead)}>\n          {chars(lead, 0)}\n        </text>\n        <text\n          x={x0 + wl + beat.gap}\n          y={beat.subjectBase}\n          style={runStyle(beat.subject)}\n        >\n          {chars(subject, lead.length + 1)}\n        </text>\n      </g>\n    );\n  }\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: t.background }}>\n      <svg\n        viewBox={`0 0 ${REF_W} ${REF_H}`}\n        preserveAspectRatio=\"xMidYMid meet\"\n        style={{ width: \"100%\", height: \"100%\", overflow: \"visible\" }}\n        aria-label={line}\n        role=\"img\"\n      >\n        {content}\n        <g ref={probe} opacity={0} aria-hidden>\n          {BEATS.map(([at, b]) =>\n            b.kind === \"glitch\" ? (\n              <g key={at} data-beat=\"glitch\">\n                <text style={runStyle(b.run)}>{line}</text>\n              </g>\n            ) : (\n              <g key={at} data-beat=\"pair\">\n                <text style={runStyle(b.lead)}>{lead}</text>\n                <text style={runStyle(b.subject)}>{subject}</text>\n              </g>\n            ),\n          )}\n        </g>\n      </svg>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/font-shuffle.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/text/font-shuffle?ref=cli — lead stays serif, subject tries on eleven typefaces; accent is the colour the line flashes before it settles. Pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette. — 51 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
