{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "block-wordmark",
  "title": "Block Wordmark",
  "description": "Logo sting and wordmark animation: blocks build into your brand name. A wordmark builds out of solid blocks — a square scales in, a coloured deck fans out around it and winds back, the stack collapses and stretches into one block per letter, then each block flashes a colour and swaps for its real letterform.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/block-wordmark/index.tsx",
      "content": "// Block Wordmark · snapcn — https://snapcn.dev/docs/logos/block-wordmark\n\"use client\";\n\nimport { loadFont as loadUltra } from \"@remotion/google-fonts/Ultra\";\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useCurrentScale,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n// Loaded through @remotion/google-fonts, never a CSS variable — a Remotion\n// bundle has none of the app's CSS, so `var(--font-…)` gets you the right face\n// in the Player and a fallback in the mp4 (design-system rule 4). It also has to\n// be resident *before* the measurement below, or every block is sized off a\n// fallback metric.\n//\n// Ultra ships a **single** weight (400). That is not a detail to work around: ask\n// for a weight Google does not serve and the browser synthesises it, which on a\n// slab this heavy smears the serifs and thickens the stems unevenly — and the\n// canvas measurement below would then be taken off that fake face and size every\n// block wrong. So the weight prop, its customizer control and this call all say\n// 400, and none of them offer anything else.\nconst { fontFamily: ULTRA, waitUntilDone: ultraReady } = loadUltra(\"normal\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nconst CLAMP = { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" } as const;\n\n// ─── Motion ────────────────────────────────────────────────────────────────────\n\n/**\n * The deck's full turn. This is an aggressive S-curve — it whips through the\n * middle third and creeps at both ends — and the repo's motion-quality rule says\n * *moderate decelerates only*. That rule is about **type**: an aggressive ease on\n * a glyph spends frames moving under half a pixel, the rasteriser rounds them to\n * nothing, and the word visibly freezes. Nothing here is a glyph. These are\n * solid-colour rounded rectangles with no hinting and no baseline to snap, and\n * the whip is the entire read of the beat — the deck has to look *shuffled*, not\n * eased. Softening this to `bezier(0.2, 0.6, 0.35, 1)` was measured against the\n * reference and destroys it: the fan opens lazily and never snaps shut.\n *\n * Fitted frame by frame against the reference, this curve lands within 2° of the\n * measured angle over the whole 360°.\n */\nconst SPIN_EASE = Easing.bezier(0.76, 0, 0.24, 1);\n\n/** The scale-in, and every settle that follows it: `1 - (1 - x)³`. */\nconst OUT_EASE = Easing.out(Easing.cubic);\n\n/** The deck dropping back onto the baseline — accelerating, a fall not a drift. */\nconst IN_EASE = Easing.in(Easing.cubic);\n\n// ─── Measurement ───────────────────────────────────────────────────────────────\n\n/** An ascender's tab, measured off the glyph. All values in probe px. */\ninterface Tab {\n  /** How far the glyph's ink rises above the x-height. */\n  height: number;\n  /** Left edge of that ink, as a fraction of the glyph's ink width. */\n  x: number;\n  /** Width of that ink, as a fraction of the glyph's ink width. */\n  width: number;\n}\n\ninterface Glyph {\n  ch: string;\n  /** True when the glyph paints no ink (a space) — it gets no block. */\n  blank: boolean;\n  /** Advance width in layout px at the probe's `fontSize`. */\n  advance: number;\n  /** `null` when the glyph does not rise above the x-height. */\n  tab: Tab | null;\n}\n\ninterface Metrics {\n  glyphs: Glyph[];\n  /**\n   * The x-height, which is the block's side.\n   *\n   * Not the cap height, which is what the reference *looks* like it uses. Its\n   * word is lowercase and its blocks are 69–70px against letters that stand 71–73\n   * tall — those letters are \"a\", \"s\" and \"e\", so 72 is that face's **x-height**,\n   * and the 27.6px tab on the first block is exactly its ascender minus its\n   * x-height. Reading it as cap height happens to work for a face whose caps and\n   * ascenders coincide, and then quietly produces no tab at all in Inter, whose\n   * ascenders are the same height as its caps. x-height is the reading that\n   * survives a change of font.\n   */\n  xHeight: number;\n  /** Distance from a letter box's top edge down to its text baseline. */\n  baselineOffset: number;\n}\n\n/**\n * Ink metrics come off a canvas, not off the DOM, because the DOM does not\n * expose them: `getBoundingClientRect()` measures a span's *layout* box, which\n * is the same height for \"b\" and for \"a\". `TextMetrics.actualBoundingBoxAscent`\n * is the real ink top, which is the only thing that can tell an ascender from an\n * x-height glyph without a hardcoded `bdfhklt` list — and a hardcoded list is\n * wrong the moment the text is Greek, Cyrillic, a capital, or an emoji.\n */\nfunction measureInk(\n  glyphs: string[],\n  fontFamily: string,\n  fontWeight: number | string,\n  fontSize: number,\n): { xHeight: number; tabs: Map<string, Tab> } | null {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.ceil(fontSize * 3);\n  canvas.height = Math.ceil(fontSize * 3);\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n  if (!ctx) return null;\n  ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;\n  ctx.textBaseline = \"alphabetic\";\n\n  const xHeight = ctx.measureText(\"x\").actualBoundingBoxAscent;\n  const capHeight = ctx.measureText(\"H\").actualBoundingBoxAscent;\n  if (!(xHeight > 0) || !(capHeight > xHeight)) return null;\n\n  // Round letters overshoot the x-height by a pixel or so at every size — \"o\"\n  // and \"e\" are drawn slightly taller than \"x\" so they don't *look* shorter. A\n  // fraction of the x-height-to-cap gap is the right scale for \"meaningfully\n  // above\", because that gap is the size of the feature we are looking for.\n  const cut = (capHeight - xHeight) * 0.15;\n\n  const origin = fontSize * 0.5;\n  const baseline = Math.round(fontSize * 2);\n  const tabs = new Map<string, Tab>();\n\n  for (const ch of glyphs) {\n    const m = ctx.measureText(ch);\n    const height = m.actualBoundingBoxAscent - xHeight;\n    if (!(height > cut)) continue;\n\n    // How wide the tab is, and which side it sits on, cannot be read off any\n    // metric — \"b\" and \"d\" have identical bounding boxes, and \"B\" has the same\n    // box as \"b\" while being solid across its whole width. So rasterise the\n    // glyph once and look at where the ink actually is above the x-height.\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n    ctx.fillStyle = \"#000\";\n    ctx.fillText(ch, origin, baseline);\n    // Read the top 60% of the tab, not all the way down to the x-height line.\n    // The last rows before that line are where a \"b\"'s bowl and an \"f\"'s crossbar\n    // start, and sampling them reports the whole letter as the stem — measured,\n    // that inflated Inter's \"b\" tab from 28% of the block to 67%.\n    const top = Math.max(0, Math.floor(baseline - m.actualBoundingBoxAscent));\n    const bottom = Math.min(canvas.height, top + Math.max(1, height * 0.6));\n    const data = ctx.getImageData(\n      0,\n      top,\n      canvas.width,\n      Math.ceil(bottom - top),\n    ).data;\n    let lo = Number.POSITIVE_INFINITY;\n    let hi = Number.NEGATIVE_INFINITY;\n    for (let i = 3; i < data.length; i += 4) {\n      if (data[i] <= 8) continue;\n      const x = (i >> 2) % canvas.width;\n      if (x < lo) lo = x;\n      if (x > hi) hi = x;\n    }\n    if (lo === Number.POSITIVE_INFINITY) continue;\n    const inkLeft = origin - m.actualBoundingBoxLeft;\n    const inkWidth = m.actualBoundingBoxRight + m.actualBoundingBoxLeft;\n    if (!(inkWidth > 0)) continue;\n    tabs.set(ch, {\n      height,\n      x: Math.max(0, (lo - inkLeft) / inkWidth),\n      width: Math.min(1, (hi - lo + 1) / inkWidth),\n    });\n  }\n\n  return { xHeight, tabs };\n}\n\n// ─── Props ─────────────────────────────────────────────────────────────────────\n\nexport interface BlockWordmarkProps {\n  /** The word to build. Any text: spaces get no block, emoji get their own. */\n  text?: string;\n  /** Size the finished wordmark is set at, in px. */\n  fontSize?: number;\n  /** Defaults to Ultra, loaded through `@remotion/google-fonts`. Ultra has one\n   *  weight (400) — see the loader note above before offering another. */\n  fontFamily?: string;\n  fontWeight?: number | string;\n  /** The wordmark's ink. Overrides the design system's `foreground`. */\n  color?: string;\n  /**\n   * The deck's cards and the flash colours, in order. Accepts an array or a\n   * comma-separated string (which is what the customizer's text control passes).\n   * The lead card is always `color`; these are the ones behind it.\n   */\n  colors?: string[] | string;\n  /** Stage behind the build. Overrides the design system's `background`. */\n  background?: string;\n\n  /** Gap between blocks, as a fraction of the block's side. */\n  blockGap?: number;\n  /** Corner radius of a block, as a fraction of its side. */\n  cornerRadius?: number;\n  /**\n   * How tall an ascender's tab grows, as a fraction of the block's height.\n   * Omitted, it is **measured** per glyph — how far that letter's ink actually\n   * rises above the x-height — which is the only value that stays right when the\n   * font changes. The reference's own face gives 0.40; Inter gives 0.33.\n   */\n  ascenderRatio?: number;\n  /**\n   * Width of that tab, as a fraction of the block's width. Omitted, it is\n   * measured too: the stem of a \"b\", the whole width of a \"B\". The reference\n   * widens its own stem by about 60% into a chunky flag — `0.415` reproduces it.\n   */\n  ascenderStemRatio?: number;\n  /**\n   * `\"square\"` (measured) makes every block a square of side = cap height, so\n   * the row is an even rhythm and each block visibly *changes* into its letter.\n   * `\"glyph\"` sizes each block to the advance width of the letter it becomes,\n   * which makes the resolve nearly in-place.\n   */\n  blockSizing?: \"square\" | \"glyph\";\n  /** Fraction of the frame width the row may occupy before it is scaled to fit. */\n  maxWidth?: number;\n\n  /** Cards in the deck, including the lead one. */\n  cards?: number;\n  /** Frames between one card's turn starting and the next card's. */\n  cardStagger?: number;\n  /** Full turns the deck makes while it fans and winds back in. */\n  spinTurns?: number;\n  /** How far the deck lifts off the baseline as it fans, as a fraction of side. */\n  liftRatio?: number;\n\n  /** Frames the first square takes to scale in. */\n  growDuration?: number;\n  /** Frames the deck's turn lasts. */\n  spinDuration?: number;\n  /** Frames a block takes to travel from the square to its slot. */\n  splitDuration?: number;\n  /** Frames the inner blocks lag the outer ones by. The split runs outside-in. */\n  splitStagger?: number;\n  /** Frames an ascender's tab takes to grow. */\n  ascenderDuration?: number;\n  /** Frames the finished block row holds before the letters resolve. */\n  holdDuration?: number;\n  /** Frames between one letter's flash and the next. The first step is half. */\n  flashStagger?: number;\n  /** Frames a block stays lit in its flash colour. */\n  flashDuration?: number;\n  /** Frames from a block lighting up to it being replaced by its letter. */\n  flashToSwap?: number;\n\n  speed?: number;\n  className?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n}\n\n/**\n * The accent palette, ours: **one hue, five shades**.\n *\n * Every card is `primary` walked toward pure white or pure black in OKLCH, so\n * the deck is a tonal ramp of the installer's own accent rather than five\n * unrelated hues — override `theme.primary` and the whole deck moves with it,\n * which a hardcoded set could never do.\n *\n * **Pure `#fff`/`#000`, deliberately, not `t.background`/`t.foreground`.** Those\n * are warm neutrals, and OKLCH interpolation carries their hue: mixing this blue\n * 28% toward the warm off-white background measures out at `rgb(0,172,196)` —\n * cyan — and 52% lands on `rgb(124,203,178)`, a green. Pure endpoints have no\n * hue to contribute, so all five stay on h≈260 and only lightness moves, which\n * is what \"shades of one blue\" has to mean.\n *\n * Alternating darker/lighter rather than a straight ramp: the cards fan out\n * overlapping, so neighbours need contrast against *each other*, not just\n * against the stage.\n *\n * The reference's own six colours are its **brand** (design-system rule 5), so\n * they are not the default — they are one prop away:\n * `colors=\"#ABF25B,#FA361A,#FCCA28,#337DFE,#F99BCA\" color=\"#0000FF\"`.\n */\nfunction defaultColors(t: SnapCnTheme): string[] {\n  return [\n    t.primary,\n    mixOklch(t.primary, \"#000000\", 0.3),\n    mixOklch(t.primary, \"#ffffff\", 0.28),\n    mixOklch(t.primary, \"#000000\", 0.55),\n    mixOklch(t.primary, \"#ffffff\", 0.52),\n  ];\n}\n\n/**\n * A wordmark that builds itself out of blocks.\n *\n * 1. A rounded square scales up off the baseline.\n * 2. Coloured squares fan out from behind it like a deck being shuffled and wind\n *    back in — one full turn each, staggered, about a **shared** pivot on the\n *    baseline. The fan is the stagger sampling one S-curve at different times;\n *    it opens and closes exactly once.\n * 3. The deck lands, dips once, and the square splits into one block per letter,\n *    outermost blocks leading. The first ascender grows a tab: the \"b\".\n * 4. The row holds, then each block flashes a colour, returns to ink, and is\n *    **hard-cut** to its real letterform. The first letter resolves last.\n *\n * Nothing crossfades and nothing morphs: measured against the reference, at each\n * swap frame the ink is already within a handful of pixels of the settled\n * wordmark and one frame earlier it is 2,000 off. The cut *is* the effect — a\n * spring or a crossfade \"to improve it\" is the one change that breaks it.\n */\nexport function BlockWordmark({\n  text = \"base\",\n  fontSize = 160,\n  fontFamily,\n  fontWeight = 400,\n  color,\n  colors,\n  background,\n  blockGap = 0.093,\n  cornerRadius = 0.18,\n  ascenderRatio,\n  ascenderStemRatio,\n  blockSizing = \"square\",\n  maxWidth = 0.92,\n  cards = 6,\n  cardStagger = 2,\n  spinTurns = 1,\n  liftRatio = 0.665,\n  growDuration = 9,\n  spinDuration = 33,\n  splitDuration = 5,\n  splitStagger = 1,\n  ascenderDuration = 12,\n  holdDuration = 54,\n  flashStagger = 4,\n  flashDuration = 3,\n  flashToSwap = 5,\n  speed = 1,\n  className,\n  theme,\n  mode,\n}: BlockWordmarkProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height, fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? ULTRA;\n  const ink = color ?? t.foreground;\n  const stage = background ?? t.background;\n  const palette =\n    (typeof colors === \"string\"\n      ? colors\n          .split(\",\")\n          .map((c) => c.trim())\n          .filter(Boolean)\n      : colors) ?? defaultColors(t);\n  const deck = palette.length > 0 ? palette : defaultColors(t);\n\n  // `Array.from` splits by code point, so an emoji stays one glyph instead of\n  // becoming two broken surrogate halves.\n  const chars = Array.from(text);\n\n  // Advance widths and the baseline come from the DOM, because they have to\n  // agree with the DOM that actually paints the letters. Ink metrics come from a\n  // canvas (see `measureInk`). Both are constant across every frame, so measure\n  // once behind `delayRender` and only release the render after the measurement\n  // has re-rendered — otherwise frame 0 is captured with the wrong geometry.\n  const probeRef = useRef<HTMLDivElement>(null);\n  const charRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const rulerRef = useRef<HTMLSpanElement>(null);\n  const [handle] = useState(() =>\n    delayRender(\"block-wordmark: measure glyphs\"),\n  );\n  const [metrics, setMetrics] = useState<Metrics | null>(null);\n  // A `<Player>` paints the whole composition through one `transform: scale()`,\n  // so `getBoundingClientRect()` below comes back in *device* px while the canvas\n  // metrics in `measureInk` are composition px. Mixing the two lays the letters\n  // out on a track ~40% too short and drops them ~50px below the block row — and\n  // only on the site, because `renderMedia` runs at scale 1.\n  const scale = useCurrentScale({ dontThrowIfOutsideOfRemotion: true });\n\n  useEffect(() => {\n    let cancelled = false;\n    const run = () => {\n      if (cancelled) return;\n      const box = probeRef.current;\n      const ruler = rulerRef.current;\n      if (!box || !ruler) {\n        continueRender(handle);\n        return;\n      }\n      const glyphChars = Array.from(text);\n      // Every rect read is divided back into composition px — see `scale`.\n      const s = scale || 1;\n      const boxTop = box.getBoundingClientRect().top / s;\n      const advances = glyphChars.map(\n        (_, i) => (charRefs.current[i]?.getBoundingClientRect().width ?? 0) / s,\n      );\n      // A zero-sized inline-block aligns its bottom edge to the text baseline, so\n      // its own top *is* the baseline. `getBoundingClientRect` rather than\n      // `offsetTop`, which is rounded to a whole pixel.\n      const baselineOffset = ruler.getBoundingClientRect().top / s - boxTop;\n      const unique = Array.from(\n        new Set(glyphChars.filter((c) => c.trim() !== \"\")),\n      );\n      const measured = measureInk(unique, face, fontWeight, fontSize);\n      setMetrics({\n        xHeight: measured?.xHeight ?? fontSize * 0.52,\n        baselineOffset: baselineOffset || fontSize * 0.86,\n        glyphs: glyphChars.map((ch, i) => ({\n          ch,\n          blank: ch.trim() === \"\",\n          advance: advances[i],\n          tab: measured?.tabs.get(ch) ?? null,\n        })),\n      });\n    };\n    // Measuring against a fallback face and then swapping in the real one is the\n    // classic way to get a wordmark whose blocks are all the wrong width.\n    // `document.fonts.ready` on its own is not that gate: it resolves straight\n    // away whenever no load happens to be pending when this effect runs, so wait\n    // on the loader's own promise too.\n    Promise.all([\n      ultraReady(),\n      typeof document !== \"undefined\" && document.fonts\n        ? document.fonts.ready\n        : null,\n    ]).then(run, run);\n    return () => {\n      cancelled = true;\n    };\n    // The deps are the raw inputs, never the derived `chars` array: `Array.from`\n    // hands back a new array every render, which would re-run the measurement\n    // (and its `delayRender`) forever. `scale` only ever cancels itself back out,\n    // so a re-run on resize produces the identical metrics.\n  }, [handle, text, face, fontWeight, fontSize, scale]);\n\n  useEffect(() => {\n    if (metrics) continueRender(handle);\n  }, [metrics, handle]);\n\n  // ─── Layout ─────────────────────────────────────────────────────────────────\n\n  const glyphs =\n    metrics?.glyphs ??\n    chars.map((ch) => ({\n      ch,\n      blank: ch.trim() === \"\",\n      advance: fontSize * 0.55,\n      tab: null as Tab | null,\n    }));\n  const xHeight = metrics?.xHeight ?? fontSize * 0.52;\n  const baselineOffset = metrics?.baselineOffset ?? fontSize * 0.86;\n\n  // Two independent static layouts, exactly as measured: a row of blocks whose\n  // side is the x-height, and a row of letters at their natural advance widths. They are *not* the same row — the blocks are an even rhythm and the\n  // letters are not — which is why the resolve has to move and resize each slot\n  // rather than just repaint it.\n  const letterEnds: number[] = [];\n  let letterRun = 0;\n  for (const g of glyphs) {\n    letterEnds.push(letterRun);\n    letterRun += g.advance;\n  }\n  const letterTotal = letterRun;\n\n  const gapPx = blockGap * xHeight;\n  /** Glyph index of each block — a space produces no block. */\n  const blockGlyph: number[] = [];\n  const blockStarts: number[] = [];\n  const blockWidths: number[] = [];\n  const blockOf: number[] = [];\n  let blockRun = 0;\n  let prevWasBlock = false;\n  for (let i = 0; i < glyphs.length; i++) {\n    const g = glyphs[i];\n    if (g.blank) {\n      // A space already separates the blocks either side of it, so it does not\n      // also get a `blockGap`; its own advance width is the word space.\n      blockRun += g.advance;\n      prevWasBlock = false;\n      blockOf.push(-1);\n      continue;\n    }\n    if (prevWasBlock) blockRun += gapPx;\n    blockOf.push(blockGlyph.length);\n    blockGlyph.push(i);\n    blockStarts.push(blockRun);\n    blockWidths.push(blockSizing === \"glyph\" ? g.advance : xHeight);\n    blockRun += blockWidths[blockWidths.length - 1];\n    prevWasBlock = true;\n  }\n  const blockTotal = blockRun;\n\n  // A long word must not run off the frame. This is a *constant* scale, folded\n  // into the measured px and into `font-size` rather than applied as a transform\n  // — a static size re-rasterises once, where an animated one boils.\n  const fit = Math.min(\n    1,\n    (width * maxWidth) / Math.max(1, letterTotal, blockTotal),\n  );\n  const side = xHeight * fit;\n  const radius = cornerRadius * side;\n  const cx = width / 2;\n  /** Height of a block's tab in fitted px — measured, unless overridden. */\n  const tabHeightOf = (g: Glyph) =>\n    g.tab === null\n      ? 0\n      : ascenderRatio === undefined\n        ? g.tab.height * fit\n        : ascenderRatio * side;\n  const rowTop =\n    side + Math.max(0, ...blockGlyph.map((i) => tabHeightOf(glyphs[i])));\n  // The one invariant across the whole build: the baseline. Everything is pinned\n  // to it — the deck's pivot, the blocks' bottom edge, the letters. The row is\n  // centred on its *ink* box, tab included, not on the square.\n  const baselineY = height / 2 + rowTop / 2;\n  const letterLeft = (width - letterTotal * fit) / 2;\n  const blockLeft = (width - blockTotal * fit) / 2;\n\n  // ─── Beats ──────────────────────────────────────────────────────────────────\n\n  const nCards = Math.max(1, Math.round(cards));\n  // The deck is visually done half the stagger's spread after the lead card's\n  // turn ends. Rounded, because the anticipation dip below peaks on a single\n  // frame: at an odd `cardStagger * (nCards - 1)` a fractional `collapseAt` puts\n  // that peak between two samples and the beat silently does not happen.\n  const collapseAt = Math.round(\n    spinDuration + (cardStagger * (nCards - 1)) / 2,\n  );\n  const splitStart = collapseAt + 2;\n  const ascenderStart = splitStart + 2;\n  const resolveStart = splitStart + holdDuration;\n\n  const grow = interpolate(frame, [0, Math.max(1, growDuration)], [0, 1], {\n    ...CLAMP,\n    easing: OUT_EASE,\n  });\n\n  /**\n   * One rigid rotation about an offset origin — spin angle *is* orbit angle.\n   *\n   * A trailing card's turn is compressed so that it *ends* by `collapseAt`. The\n   * stagger is on the starts, so the fan still opens exactly as measured; but a\n   * card whose turn ran past the collapse would still be 6° short — a 10px\n   * corner sticking out from behind the lead square — on the frame the deck is\n   * meant to be gone, and would then either pop out of existence or show through\n   * the gaps as the square splits. The lead card (`card === 0`) is never\n   * compressed: `collapseAt >= spinDuration` by construction.\n   */\n  const turn = (card: number) => {\n    const start = card * cardStagger;\n    const span = Math.max(1, Math.min(spinDuration, collapseAt - start));\n    return interpolate(frame - start, [0, span], [0, 360 * spinTurns], {\n      ...CLAMP,\n      easing: SPIN_EASE,\n    });\n  };\n\n  // The lift is one translate shared by every card — a version where each card's\n  // lift lags with its own stagger measures much worse against the reference.\n  // Out on an overdamped spring, back down on an accelerating fall.\n  const lift =\n    liftRatio *\n    side *\n    (spring({\n      frame: Math.max(0, frame - 2),\n      fps,\n      config: { damping: 60, stiffness: 330, mass: 1 },\n    }) -\n      interpolate(frame, [collapseAt - 9, collapseAt], [0, 1], {\n        ...CLAMP,\n        easing: IN_EASE,\n      }));\n\n  // One frame of anticipation before the split: the square lifts 3% of its side\n  // and squashes about 1%. It is a single frame at 30fps and it is what sells\n  // the split.\n  const dip = interpolate(\n    frame,\n    [collapseAt, collapseAt + 1, collapseAt + 3],\n    [0, 1, 0],\n    CLAMP,\n  );\n\n  /** Blocks travel outside-in: the outermost pair leads, each step in lags. */\n  const splitRank = (b: number) => {\n    const mid = (blockWidths.length - 1) / 2;\n    return Math.round(mid - Math.abs(b - mid));\n  };\n  const splitProgress = (b: number) =>\n    interpolate(\n      frame - splitStart - splitRank(b) * splitStagger,\n      [0, Math.max(1, splitDuration)],\n      [0, 1],\n      { ...CLAMP, easing: OUT_EASE },\n    );\n\n  const tabGrow = interpolate(\n    frame,\n    [ascenderStart, ascenderStart + Math.max(1, ascenderDuration)],\n    [0, 1],\n    { ...CLAMP, easing: OUT_EASE },\n  );\n\n  // The letters resolve left to right *from the second one*, wrapping to the\n  // first letter last, and the first gap is half a step — a deliberately\n  // irregular rhythm that a uniform stagger visibly mistimes.\n  const order: number[] = [];\n  for (let b = 1; b < blockWidths.length; b++) order.push(b);\n  if (blockWidths.length > 0) order.push(0);\n  const cue = new Map<number, number>();\n  order.forEach((b, k) => {\n    cue.set(b, k === 0 ? 0 : flashStagger * k - flashStagger / 2);\n  });\n  const flashOf = new Map<number, string>();\n  order.forEach((b, k) => {\n    flashOf.set(b, deck[k % deck.length]);\n  });\n\n  const ready = metrics !== null;\n  const isRendering = getRemotionEnvironment().isRendering;\n  const accel = isRendering ? null : { willChange: \"transform\" as const };\n  const squareLeft = cx - side / 2;\n\n  return (\n    <AbsoluteFill\n      className={className}\n      style={{\n        backgroundColor: stage,\n        overflow: \"hidden\",\n        fontFamily: face,\n        fontWeight,\n        // Hinting re-snaps every stem as a glyph changes size, so the letterforms\n        // literally change shape frame to frame. Off, they hold still.\n        textRendering: \"geometricPrecision\",\n      }}\n    >\n      {/* Hidden probe. Laid out with the same face, weight and size as the real\n          letters, so its advance widths are the widths that will be painted. */}\n      <div\n        ref={probeRef}\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          visibility: \"hidden\",\n          pointerEvents: \"none\",\n          whiteSpace: \"pre\",\n          lineHeight: 1,\n          fontSize,\n        }}\n      >\n        {chars.map((ch, i) => (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: glyphs are positional and never reorder\n            key={i}\n            ref={(el) => {\n              charRefs.current[i] = el;\n            }}\n            style={{ display: \"inline-block\", whiteSpace: \"pre\" }}\n          >\n            {ch}\n          </span>\n        ))}\n        <span\n          ref={rulerRef}\n          style={{ display: \"inline-block\", width: 0, height: 0 }}\n        />\n      </div>\n\n      <AbsoluteFill\n        style={{\n          opacity: ready ? 1 : 0,\n          // The lift moves the deck's shared pivot; the dip squashes it onto the\n          // baseline. Both pivot on the baseline, so the bottom edge never moves.\n          transformOrigin: `50% ${baselineY}px`,\n          transform: `translateY(${-lift - dip * side * 0.03}px) scaleY(${1 - dip * 0.013})`,\n        }}\n      >\n        {/* The deck, back to front. The lead card is the block row below, which\n            paints over all of them — it is never occluded. */}\n        {frame < collapseAt + 1 &&\n          Array.from({ length: nCards - 1 }, (_, i) => nCards - 1 - i).map(\n            (k) => (\n              <div\n                key={`card-${k}`}\n                style={{\n                  position: \"absolute\",\n                  left: squareLeft,\n                  top: baselineY - side,\n                  width: side,\n                  height: side,\n                  borderRadius: radius,\n                  backgroundColor: deck[(k - 1) % deck.length],\n                  // Bottom centre, not the middle. The orbit radius is exactly\n                  // half the side because the pivot is on the square's own edge:\n                  // get this wrong and the deck spins in place instead of\n                  // sweeping.\n                  transformOrigin: \"50% 100%\",\n                  transform: `rotate(${turn(k)}deg) scale(${grow})`,\n                  ...accel,\n                }}\n              />\n            ),\n          )}\n\n        {/* The block row. Every block starts life *as* the lead card — same box,\n            same transform — so beats 1–3 draw one square, and the split simply\n            lets them walk apart. */}\n        {blockWidths.map((w, b) => {\n          const p = splitProgress(b);\n          const g = glyphs[blockGlyph[b]];\n          const swapAt = resolveStart + (cue.get(b) ?? 0) + flashToSwap;\n          if (frame >= swapAt) return null;\n          const flashOn = resolveStart + (cue.get(b) ?? 0);\n          const lit = frame >= flashOn && frame < flashOn + flashDuration;\n          const fill = lit ? (flashOf.get(b) ?? ink) : ink;\n          const left =\n            squareLeft + (blockLeft + blockStarts[b] * fit - squareLeft) * p;\n          const bw = side + (w * fit - side) * p;\n          const tab = g?.tab ?? null;\n          const tabH = tab ? tabGrow * tabHeightOf(g) : 0;\n          // Measured off the glyph: a \"b\" gives a narrow stem at its left edge,\n          // a \"B\" gives the block's whole width, an emoji gives most of it.\n          const tabW = tab ? bw * (ascenderStemRatio ?? tab.width) : 0;\n          const tabX = tab\n            ? Math.min(Math.max(0, tab.x * bw), Math.max(0, bw - tabW))\n            : 0;\n          const notched = tabH > radius;\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: blocks are a fixed positional row — the index IS a block's identity\n              key={`block-${b}`}\n              style={{\n                position: \"absolute\",\n                left,\n                top: baselineY - side,\n                width: bw,\n                height: side,\n                borderRadius: radius,\n                // Only kill the corner the tab actually lands on, or the flag\n                // grows a notch where the two shapes meet.\n                borderTopLeftRadius: notched && tabX < 1 ? 0 : radius,\n                borderTopRightRadius:\n                  notched && tabX + tabW > bw - 1 ? 0 : radius,\n                backgroundColor: fill,\n                transformOrigin: \"50% 100%\",\n                transform: `rotate(${turn(0)}deg) scale(${grow})`,\n                ...accel,\n              }}\n            >\n              {/* The tab grows in **height only** — it is full width from the\n                  first frame it exists. Scaling the whole silhouette up out of\n                  the block would widen the stem too, and that is a different,\n                  wrong shape. */}\n              {tabH > 0 && (\n                <div\n                  style={{\n                    position: \"absolute\",\n                    bottom: \"100%\",\n                    left: tabX,\n                    width: tabW,\n                    height: tabH,\n                    backgroundColor: fill,\n                    borderTopLeftRadius: radius,\n                    borderTopRightRadius: radius,\n                  }}\n                />\n              )}\n            </div>\n          );\n        })}\n\n        {/* The letters. Each one is cut in at its final position, final size and\n            final ink — the flash colour never touches the letterform. */}\n        {glyphs.map((g, i) => {\n          const b = blockOf[i];\n          if (b < 0) return null;\n          const swapAt = resolveStart + (cue.get(b) ?? 0) + flashToSwap;\n          if (frame < swapAt) return null;\n          return (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: glyphs are positional and never reorder\n              key={`letter-${i}`}\n              style={{\n                position: \"absolute\",\n                left: letterLeft + letterEnds[i] * fit,\n                top: baselineY - baselineOffset * fit,\n                fontSize: fontSize * fit,\n                lineHeight: 1,\n                whiteSpace: \"pre\",\n                color: ink,\n              }}\n            >\n              {g.ch}\n            </span>\n          );\n        })}\n      </AbsoluteFill>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/block-wordmark.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/logos/block-wordmark?ref=cli — pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette and a bare Remotion render is black. — 50 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
