{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-collapse",
  "title": "Logo Collapse",
  "description": "Logo reveal intro: a stack of photos flicks through and collapses into your logo. A stack of shots flicked through, collapsing into your mark, and the wordmark landing beside it. One card holds the middle of the frame and cuts to the next shot, each smaller and each held for less time than the last, so the sequence winds itself up instead of running at a constant clip. Nothing cross-fades and nothing tweens between shots — the reference holds each card at a fixed size for its whole beat and then cuts, which reads as a stack of prints being dealt rather than as a zoom. At the end of the run the card cuts to a plain tile with the mark on it, the mark overshoots its resting size by a third and settles in three frames, the tile goes, and the mark steps aside twice to make room for the word.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/logo-collapse/index.tsx",
      "content": "// Logo Collapse · snapcn — https://snapcn.dev/docs/logos/logo-collapse\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/InterTight\";\nimport type { CSSProperties } from \"react\";\nimport { forwardRef, useCallback, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  getRemotionEnvironment,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  Item,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n} from \"@/lib/snap-cn-ui\";\n\n/**\n * A stack of shots flicked through, collapsing into the mark, and the wordmark\n * landing beside it.\n *\n * One square card holds the middle of the frame and cuts to the next shot, each\n * one smaller and each held for less time than the last, so the sequence winds\n * itself up rather than running at a constant clip. At the end of the run the\n * card does not shrink away — it *collapses*, overshooting past its resting size\n * in a frame and settling back. Then the mark steps aside and the wordmark takes\n * the space it made.\n *\n * ## The cuts are cuts\n *\n * Nothing cross-fades and nothing tweens between shots. The reference holds each\n * card at a fixed size for its whole beat — measured, the box is constant to the\n * pixel across every frame of a hold, and then a different size on the next one.\n * A card that eased between sizes would read as a zoom; a card that cuts reads as\n * a stack of prints being dealt, which is the whole idea.\n *\n * That is also why `sizes` and `holds` are parallel lists of literals rather than\n * a ratio and a duration. The reference's own run is 0.512, 0.476, 0.458, 0.414,\n * 0.339 of the frame height on beats of 1, 6, 5, 4, 2 and 4 frames — neither\n * series is geometric, and a formula that fitted one would miss the other.\n *\n * ## The collapse overshoots\n *\n * 30px, then 24, then 22, on three consecutive frames. Two frames of overshoot is\n * the difference between a mark that lands and a mark that is simply smaller than\n * the card was — and it is the only place in the scene where anything moves\n * between frames rather than cutting.\n *\n * ## The mark steps aside in two\n *\n * Centred alone at 294.5; then 282.0 for four frames; then 248.5 as the wordmark\n * appears whole. Two steps, held, not an ease — the same grammar as the cards.\n */\n\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"600\", \"700\"],\n  subsets: [\"latin\"],\n});\n\n/* ─────────────────────────────────────────────────────────────────────────\n   The stage, in the recording's own 592 × 336 pixels\n   ───────────────────────────────────────────────────────────────────────── */\n\nexport const REF_W = 592;\nexport const REF_H = 336;\n\n/** Where the card, and then the mark, sits. */\nexport const STAGE_X = 294;\nexport const STAGE_Y = 167.5;\n\n/**\n * The mark's box, frame by frame, from the moment the stack runs out.\n *\n * It lands oversized and settles in three steps rather than easing: 32, 25, 24,\n * then 23 for the rest of the scene. Measured on the mark's orange core, which\n * is the only part of it a threshold can find honestly — the glow around it\n * reads as ink to anything looser and makes the landing look 40% bigger than it\n * is.\n */\nexport const MARK = 22;\nexport const LAND: readonly number[] = [32, 25, 24, 24];\n\n/**\n * The tile the stack collapses onto, before the mark is left alone.\n *\n * This is the beat that is easy to miss and impossible to unsee: the photo card\n * does not shrink into the mark, it *cuts* to a plain light tile of 60px with\n * the mark sitting on it, holds for two frames, and only then does the tile go.\n * It is very faint — luma 233 against the page's 247, which is why nothing\n * thresholded at 200 finds it — and it is what makes the collapse read as a\n * stack of prints resolving into a logo rather than as a card that shrank.\n */\nexport const TILE = 60;\nexport const TILE_FRAMES = 2;\n\n/** Left edge of the finished lockup, and where the word starts inside it. */\nexport const LOCK_X = 237;\nexport const WORD_X = 264;\n\n/** Cap height of the wordmark, measured on its own ink. */\nexport const WORD_CAP = 19.37;\n/** Top of the wordmark's box. Measured, not derived from a line-height ratio. */\nexport const WORD_TOP = 155.74;\n/**\n * Cap height → font-size, and the tracking a logotype is set at.\n *\n * A wordmark is lettering, not type: the reference's is 4.149 cap-widths for six\n * glyphs, and the nearest face on the shelf sets the same six at 4.634 — twelve\n * per cent loose. That gap is tracking, not the wrong face. Inter Tight at\n * −0.073em closes it; every other candidate measured wider still (Archivo 4.786,\n * Plus Jakarta 4.877, Space Grotesk 4.975, Manrope 5.011, Poppins 5.715).\n */\nexport const WORD_EM = 1.326;\nexport const WORD_TRACK = -0.073;\n\n/** The mark's waypoint: where it waits before the wordmark arrives. */\nexport const STEP_X = 281.5;\n\n/** Frames, on the scene's own 30fps clock. */\nexport const STEP_AT = 28;\nexport const WORD_AT = 30;\nexport const DOT_AT: readonly number[] = [37, 42];\n\n/**\n * The two marks that bracket the lockup, and the ping each one arrives on.\n *\n * They do not simply appear: each lands at 7px in full accent and settles to 4px\n * and about a third of its ink over five frames. Measured on both — the upper\n * one is already pale and small by the frame the lower one lands, which is the\n * only reason you can tell they are two beats and not one.\n */\nexport const DOTS: readonly (readonly [number, number])[] = [\n  [229, 147],\n  [229, 191],\n];\nexport const DOT_IN = 7;\nexport const DOT_OUT = 4;\nexport const DOT_SETTLE = 5;\nexport const DOT_FADE = 0.34;\n\nexport interface LogoCollapseProps {\n  /**\n   * The shots, split by `|`. The card cuts through them in order.\n   *\n   * Not by comma: a `data:` URL has one of its own right after the encoding, and\n   * splitting on it hands the browser half a URL and a card that renders as a\n   * broken-image glyph.\n   */\n  images?: string;\n  /**\n   * Each shot's card, as a share of the frame height.\n   *\n   * `0.48` is a square; `0.515x0.5625` is a portrait one. Real photo sets are\n   * not all one shape, and the reference's second shot is measurably taller\n   * than it is wide — forcing it square crops fifteen pixels off a print that\n   * was never square.\n   */\n  sizes?: string;\n  /** Frames each shot is held for. */\n  holds?: string;\n  /** The mark the stack collapses into. */\n  mark?: string;\n  /** The word that lands beside it. */\n  wordmark?: string;\n  /** Optional plate behind everything — a paper, a gradient, a still. */\n  background?: string;\n  /** The brand colour the two bracket marks ping in. Blank uses the accent. */\n  accent?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  fontFamily?: string;\n}\n\nexport function LogoCollapse({\n  images = \"https://media.snapcn.dev/demos/posters/count-grid.webp|https://media.snapcn.dev/demos/posters/moodboard-reveal.webp|https://media.snapcn.dev/demos/posters/orbit-gallery.webp|https://media.snapcn.dev/demos/posters/hero-launch.webp|https://media.snapcn.dev/demos/posters/phone-frame.webp|https://media.snapcn.dev/demos/posters/terminal-simulator.webp\",\n  sizes = \"0.82x0.461,0.72x0.405,0.63x0.354,0.55x0.309,0.46x0.259,0.36x0.203\",\n  holds = \"1,5,5,4,2,5\",\n  mark = \"/logo/snapcn.png\",\n  wordmark = \"snapcn\",\n  background = \"\",\n  accent = \"\",\n  theme,\n  mode = \"light\",\n  fontFamily = \"Default\",\n}: LogoCollapseProps) {\n  const frame = useCurrentFrame();\n  const { width, height } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily) ?? SANS;\n\n  const u = height / REF_H;\n  const ox = (width - REF_W * u) / 2;\n\n  const shots = images\n    .split(\"|\")\n    .map((s) => s.trim())\n    .filter(Boolean);\n  const size = sizes.split(\",\").map((s) => {\n    const [w, h] = s.trim().split(\"x\");\n    const sw = Number(w);\n    return [sw, h === undefined ? sw : Number(h)] as const;\n  });\n  const hold = holds.split(\",\").map((s) => Number(s.trim()));\n\n  // Which shot is up, and the frame the stack runs out on.\n  let at = 0;\n  let shot = -1;\n  for (let i = 0; i < shots.length; i++) {\n    const end = at + (hold[i] ?? 0);\n    if (frame < end) {\n      shot = i;\n      break;\n    }\n    at = end;\n  }\n  const collapseAt = hold\n    .slice(0, shots.length)\n    .reduce((a, b) => a + (b || 0), 0);\n\n  const ink = t.foreground;\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: t.background }}>\n      {background && (\n        <Plate\n          src={background}\n          style={{ position: \"absolute\", inset: 0, objectFit: \"cover\" }}\n        />\n      )}\n      <div\n        style={{\n          position: \"absolute\",\n          left: ox,\n          top: 0,\n          width: REF_W * u,\n          height: REF_H * u,\n          fontFamily: face,\n          textRendering: \"geometricPrecision\",\n        }}\n      >\n        {shot >= 0 ? (\n          <Item key={shot} index={shot}>\n            <Plate\n              src={shots[shot] ?? \"\"}\n              style={box(\n                (size[shot]?.[0] ?? 0.4) * REF_H,\n                (size[shot]?.[1] ?? 0.4) * REF_H,\n                STAGE_X,\n                STAGE_Y,\n                u,\n              )}\n            />\n          </Item>\n        ) : (\n          <Lockup\n            frame={frame}\n            since={frame - collapseAt}\n            mark={mark}\n            word={wordmark}\n            ink={ink}\n            accent={accent || t.primary}\n            tile={t.muted}\n            u={u}\n          />\n        )}\n      </div>\n    </AbsoluteFill>\n  );\n}\n\n/** A box of `w × h` reference pixels centred on `(cx, cy)`, in real pixels. */\nfunction box(\n  w: number,\n  h: number,\n  cx: number,\n  cy: number,\n  u: number,\n): CSSProperties {\n  return {\n    position: \"absolute\",\n    left: (cx - w / 2) * u,\n    top: (cy - h / 2) * u,\n    width: w * u,\n    height: h * u,\n    objectFit: \"cover\",\n  };\n}\n\n/** The tile, the mark landing, stepping aside, and the word arriving. */\nfunction Lockup({\n  frame,\n  since,\n  mark,\n  word,\n  ink,\n  accent,\n  tile,\n  u,\n}: {\n  /** The scene's own frame, which the beats below are written in. */\n  frame: number;\n  /** Frames since the stack ran out, which the landing is written in. */\n  since: number;\n  mark: string;\n  word: string;\n  ink: string;\n  accent: string;\n  tile: string;\n  u: number;\n}) {\n  const size = LAND[since] ?? MARK;\n  const worded = frame >= WORD_AT;\n  const cx = worded ? LOCK_X + MARK / 2 : frame >= STEP_AT ? STEP_X : STAGE_X;\n  return (\n    <>\n      {since < TILE_FRAMES && (\n        <div\n          style={{\n            ...box(TILE, TILE, STAGE_X, STAGE_Y, u),\n            background: tile,\n          }}\n        />\n      )}\n      <Plate src={mark} style={box(size, size, cx, STAGE_Y, u)} />\n      {worded && (\n        <div\n          style={{\n            position: \"absolute\",\n            left: WORD_X * u,\n            top: WORD_TOP * u,\n            fontSize: WORD_CAP * WORD_EM * u,\n            lineHeight: 1,\n            fontWeight: 700,\n            letterSpacing: `${WORD_TRACK}em`,\n            color: ink,\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          {word}\n        </div>\n      )}\n      {DOTS.map(([dx, dy], i) => {\n        const since = frame - (DOT_AT[i] ?? Number.POSITIVE_INFINITY);\n        if (since < 0) return null;\n        const k = Math.min(1, since / DOT_SETTLE);\n        const d = DOT_IN + (DOT_OUT - DOT_IN) * k;\n        return (\n          <div\n            key={`${dx}-${dy}`}\n            style={{\n              position: \"absolute\",\n              left: (dx - d / 2) * u,\n              top: (dy - d / 2) * u,\n              width: d * u,\n              height: d * u,\n              background: accent,\n              opacity: 1 + (DOT_FADE - 1) * k,\n            }}\n          />\n        );\n      })}\n    </>\n  );\n}\n\n/**\n * A root-relative asset is a `public/` file. Only the site's own Player serves\n * it at that path; Remotion Studio and a render know its URL through\n * `staticFile()` alone — so every environment but the Player rewrites it.\n */\nfunction resolveSrc(src: string): string {\n  const isLocal = src.startsWith(\"/\") && !src.startsWith(\"//\");\n  if (!isLocal || getRemotionEnvironment().isPlayer) return src;\n  // A value that already came out of staticFile() carries the static base\n  // (`/static-<hash>/…`); running it through again would prefix it twice.\n  const base = staticFile(\"_\").slice(0, -2);\n  if (base && src.startsWith(`${base}/`)) return src;\n  try {\n    return staticFile(src.replace(/^\\/+/, \"\"));\n  } catch {\n    return src;\n  }\n}\n\n/**\n * An image that holds the render until it has actually loaded.\n *\n * Without it a frame can be captured before the picture arrives and the card\n * comes out empty — invisible in the Player, where an image simply appears, and\n * silent in an mp4 until somebody watches it. Remotion's own `Img` is avoided for\n * the reason `hero-launch` documents: it awaits `decode()`, which the headless\n * compositor rejects for some progressive JPEGs and hangs the export.\n *\n * Takes a `ref` so Remotion Studio can outline a shot (see `Item`); its `style`\n * already arrives with Studio's merged in.\n */\nconst Plate = forwardRef<\n  HTMLImageElement,\n  {\n    src: string;\n    style: CSSProperties;\n  }\n>(function Plate({ src, style }, ref) {\n  const [handle] = useState(() => delayRender(`logo-collapse: ${src}`));\n  const release = useCallback(() => continueRender(handle), [handle]);\n  return (\n    // biome-ignore lint/performance/noImgElement: Remotion frame, not a Next route.\n    <img // eslint-disable-line @remotion/warn-native-media-tag -- onLoad releases the frame; <Img> hangs on some JPEGs\n      ref={ref}\n      src={resolveSrc(src)}\n      alt=\"\"\n      // Tailwind's preflight sets `img { max-width: 100% }`, which collapses an\n      // image inside a shrink-to-fit box to zero pixels wide on the site while\n      // rendering perfectly in an mp4. Size it here and say so.\n      style={{ ...style, maxWidth: \"none\" }}\n      onLoad={release}\n      onError={release}\n    />\n  );\n});\n",
      "type": "registry:component",
      "target": "components/snap-cn/logo-collapse.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/logos/logo-collapse?ref=cli — images is the stack, split by `|` (not by comma: a data: URL has one of its own); sizes and holds are parallel to it, one entry per shot, sizes as a share of the frame height where `0.48` is square and `0.82x0.461` is 16:9; mark is what the stack collapses into and wordmark the word that lands beside it. background takes an optional plate and accent the colour the two brackets ping in. Pass mode=\"dark\" if you are rendering onto a dark frame — these default to shadcn's light palette. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
