{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "logo-drift",
  "title": "Logo Drift",
  "description": "Logo wall animation: a logo cloud of integrations drifting behind a headline. A field of tiles drifting past a headline while the camera pulls steadily back, so the wall of things-it-works-with keeps arriving from every edge. The pull-back is one linear scale measured at 0.176 per second across every tile; the words do not fade in, they arrive 1.216× large and 4.3px soft and land in 140ms.",
  "dependencies": [
    "remotion",
    "@remotion/google-fonts"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/logo-drift/index.tsx",
      "content": "// Logo Drift · snapcn — https://snapcn.dev/docs/logos/logo-drift\n\"use client\";\n\nimport { loadFont as loadSans } from \"@remotion/google-fonts/Inter\";\nimport {\n  AbsoluteFill,\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  Item,\n  mixOklch,\n  resolveFont,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\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 a `var(--font-…)` gets you the right face\n// in the Player and a fallback in the mp4 (design-system rule 4).\nconst { fontFamily: SANS } = loadSans(\"normal\", {\n  weights: [\"400\", \"500\", \"600\"],\n  subsets: [\"latin\"],\n});\n\n// --- The stage -------------------------------------------------------------\n//\n// The reference recording is 810×458 — near enough 16:9 — and every number\n// below was read off its frames, so there is no second conversion to get wrong.\n// `stageScale` fits this rectangle into whatever composition it is given.\n\nconst REF_W = 810;\nconst REF_H = 458;\n\n// --- Measured timeline, in seconds -----------------------------------------\n\n/** Per-word entrance: it arrives 22% large and 4.3px soft, and lands in 140ms. */\nconst WORD_DUR = 0.14;\nconst WORD_SCALE = 1.216;\nconst WORD_BLUR = 4.3;\n/** Exit: the line recedes to 0.80, blurs to 6.5px and goes. */\nconst EXIT_DUR = 0.34;\nconst EXIT_SCALE = 0.8;\nconst EXIT_BLUR = 6.5;\n\n/** The entrance eases hard out — half the distance in the first quarter. */\nconst WORD_EASE = Easing.out(Easing.quad);\n\n// --- Pure helpers (unit-tested) --------------------------------------------\n\n/**\n * The pull-back, as a plain linear scale on the whole tile field.\n *\n * This is the measurement the whole component hangs off. Every tile in the\n * reference shrinks at the *same relative rate* — fit its on-screen size against\n * time and you get a straight line, and dividing that slope by the tile's own\n * size gives 0.176 per second for all seven tiles that are on screen long enough\n * to fit (spread: ±0.0016). Not a perspective dolly — `1/size` is visibly not\n * linear — just one linear scale over a field that is much bigger than the frame.\n */\nexport function pullbackScale(t: number, rate: number, floor = 0.02): number {\n  return Math.max(floor, 1 - rate * t);\n}\n\nexport interface DriftTile {\n  /** One to three characters, set large — a mark, not a paragraph. */\n  glyph: string;\n  /** Optional word under the glyph, for tiles big enough to carry one. */\n  label?: string;\n  background: string;\n  color?: string;\n  /** Position at t=0, in stage px from the centre, before the pull-back. */\n  x: number;\n  y: number;\n  /** Edge length at t=0, in stage px, before the pull-back. */\n  size: number;\n  /** Constant drift, in stage px per second. */\n  vx?: number;\n  vy?: number;\n  /** Seconds at which it starts fading up. */\n  at?: number;\n  /** Corner radius as a fraction of the tile's edge. Defaults to `tileRadius`. */\n  radius?: number;\n}\n\nexport interface PlacedTile {\n  left: number;\n  top: number;\n  size: number;\n  opacity: number;\n}\n\n/**\n * Where a tile is, how big, and how far up its fade has got.\n *\n * The field drifts *and* the camera pulls back, and those are two different\n * things: `x + vx·t` is where the tile has got to in the field, and the scale\n * outside it is the camera. Fitted that way, six of the reference's seven\n * long-lived tiles land within **0.3–1.0px per frame across their whole path**;\n * a static field (no drift) misses by 32px, and a perspective dolly cannot\n * produce a size that falls linearly at all.\n */\nexport function placeTile(\n  tile: DriftTile,\n  t: number,\n  o: { rate: number; fade: number; speed: number; scale: number },\n): PlacedTile {\n  const k = pullbackScale(t, o.rate);\n  const wx = tile.x + (tile.vx ?? 0) * o.speed * t;\n  const wy = tile.y + (tile.vy ?? 0) * o.speed * t;\n  const size = k * tile.size * o.scale;\n  const age = t - (tile.at ?? 0);\n  // An exponential approach, not a ramp: the reference's tiles are ~50% up\n  // 280ms after they appear and still creeping toward 1 a second later, which\n  // is the shape a `1 − e^(−age/τ)` gives and no linear fade does.\n  const opacity = age <= 0 ? 0 : 1 - Math.exp(-age / Math.max(1e-3, o.fade));\n  return {\n    left: REF_W / 2 + k * wx - size / 2,\n    top: REF_H / 2 + k * wy - size / 2,\n    size,\n    opacity,\n  };\n}\n\nexport interface WordState {\n  scale: number;\n  blur: number;\n  opacity: number;\n}\n\n/**\n * A word's entrance, and the whole line's exit, in one place.\n *\n * The entrance is **not** a fade. Measure the ink and it is conserved from the\n * first frame the word exists — what changes is that it is 1.216× too big and\n * carrying 4.3px of blur, both of which resolve in 140ms. Words land every\n * 150ms, so each one finishes exactly as the next begins.\n */\nexport function wordState(\n  t: number,\n  index: number,\n  o: {\n    at: number;\n    stagger: number;\n    dur: number;\n    scale: number;\n    blur: number;\n    exitAt: number;\n    exitDur: number;\n    exitScale: number;\n    exitBlur: number;\n  },\n): WordState {\n  const start = o.at + index * o.stagger;\n  if (t < start) return { scale: o.scale, blur: o.blur, opacity: 0 };\n  const p = interpolate(t, [start, start + o.dur], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: WORD_EASE,\n  });\n  const q = interpolate(t, [o.exitAt, o.exitAt + o.exitDur], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  return {\n    scale: (o.scale + (1 - o.scale) * p) * (1 + (o.exitScale - 1) * q),\n    blur: o.blur * (1 - p) + o.exitBlur * q,\n    opacity: 1 - q,\n  };\n}\n\n// --- Content ---------------------------------------------------------------\n\n/**\n * The default field: the stack a snapcn component drops into.\n *\n * Positions, sizes, drifts and entry times are the ones fitted to the reference\n * recording, so the choreography is the measured one. The **labels and the paint\n * are ours** — a component that ships to strangers has no business carrying\n * somebody else's marks, and a wordmark tile says \"works with this\" without\n * borrowing a logo to say it.\n */\nexport const SNAPCN_STACK: DriftTile[] = [\n  {\n    glyph: \"Rm\",\n    label: \"Remotion\",\n    background: \"#1f2430\",\n    color: \"#ffffff\",\n    x: 163,\n    y: 232,\n    vx: -54,\n    vy: 12,\n    size: 109,\n    at: 0.28,\n  },\n  {\n    glyph: \"sh\",\n    label: \"shadcn/ui\",\n    background: \"#f2f4f7\",\n    color: \"#16181d\",\n    x: 128,\n    y: -209,\n    vx: 81,\n    vy: 32,\n    size: 122,\n    at: 0.48,\n  },\n  {\n    glyph: \"Re\",\n    label: \"React\",\n    background: \"#10131a\",\n    color: \"#7dd3fc\",\n    x: -384,\n    y: 344,\n    vx: -27,\n    vy: -68,\n    size: 200,\n    at: 0.95,\n  },\n  {\n    glyph: \"N\",\n    label: \"Next.js\",\n    background: \"#e7e8ea\",\n    color: \"#16181d\",\n    x: 519,\n    y: 45,\n    vx: -31,\n    vy: 64,\n    size: 144,\n    at: 1.51,\n  },\n  {\n    glyph: \"tw\",\n    label: \"Tailwind\",\n    background: \"#f1ece2\",\n    color: \"#8a5a2b\",\n    x: -692,\n    y: -125,\n    vx: 41,\n    vy: -71,\n    size: 214,\n    at: 1.55,\n  },\n  {\n    glyph: \"Fg\",\n    label: \"Figma\",\n    background: \"#2b3a36\",\n    color: \"#ffffff\",\n    x: -350,\n    y: -339,\n    vx: 65,\n    vy: -26,\n    size: 188,\n    at: 1.75,\n  },\n  {\n    glyph: \"Mo\",\n    label: \"Motion\",\n    background: \"#f4703a\",\n    color: \"#ffffff\",\n    x: 672,\n    y: -277,\n    vx: 2,\n    vy: 81,\n    size: 256,\n    at: 1.76,\n  },\n  {\n    glyph: \"TS\",\n    label: \"TypeScript\",\n    background: \"#2f6fdb\",\n    color: \"#ffffff\",\n    x: -721,\n    y: 211,\n    vx: 9,\n    vy: -84,\n    size: 215,\n    at: 2.06,\n  },\n  {\n    glyph: \"▲\",\n    label: \"Vercel\",\n    background: \"#0b0b0d\",\n    color: \"#ffffff\",\n    x: 676,\n    y: 348,\n    vx: -65,\n    vy: 52,\n    size: 244,\n    at: 2.48,\n  },\n];\n\n// --- Props -----------------------------------------------------------------\n\nexport interface LogoDriftProps {\n  /** The line that writes itself, one word at a time. */\n  headline?: string;\n  /** The field. Positions are stage px from the centre, before the pull-back. */\n  tiles?: DriftTile[];\n\n  // --- Motion, in seconds and per-second rates. ---\n  /** How fast the camera pulls back, as a fraction of scale per second. */\n  pullback?: number;\n  /** Multiplies every tile's drift, and every tile's size. */\n  tileSpeed?: number;\n  tileScale?: number;\n  /** Time constant of a tile's fade-up. */\n  tileFade?: number;\n  /** Corner radius as a fraction of a tile's edge. */\n  tileRadius?: number;\n\n  /** When the first word lands, and the gap between words. */\n  wordAt?: number;\n  wordStagger?: number;\n  /** How long one word takes to land, and what it arrives as. */\n  wordDuration?: number;\n  wordScale?: number;\n  wordBlur?: number;\n\n  /** When the line leaves, over how long, and what it leaves as. */\n  exitAt?: number;\n  exitDuration?: number;\n  exitScale?: number;\n  exitBlur?: number;\n\n  // --- Size and paint. ---\n  /** Headline type size, in stage px. */\n  fontSize?: number;\n  fontWeight?: number;\n  /** Glyph size inside a tile, as a fraction of its edge. */\n  glyphScale?: number;\n  /** Show the soft wash behind the headline, and what colour it is. */\n  glow?: boolean;\n  glowColor?: string;\n  glowOpacity?: number;\n  /** Accent used for the wash when `glowColor` is not given. */\n  accentColor?: string;\n  theme?: Partial<SnapCnTheme>;\n  mode?: \"light\" | \"dark\";\n  /**\n   * The face this scene paints its words in — a label from `fonts.ts`\n   * (\"Inter\", \"Space Grotesk\", \"Instrument Serif\") or a CSS family you have\n   * loaded yourself. Unset, the scene keeps the face it was designed around.\n   *\n   * Overrides `theme.fontFamily`, which is how a brand kit re-skins a whole\n   * timeline from one value.\n   */\n  fontFamily?: string;\n  speed?: number;\n}\n\n// --- Main composition ------------------------------------------------------\n\n/**\n * A field of tiles drifting past a headline while the camera pulls steadily\n * back, so the wall of things-it-works-with keeps arriving from every edge.\n *\n * ## Three measurements, and they are the component\n *\n * **The pull-back is one linear scale.** Every tile in the reference shrinks at\n * the same *relative* rate: fit on-screen size against time and it is a straight\n * line, and dividing the slope by the tile's own size gives **0.176 per second**\n * for every tile long enough to fit, to ±0.0016. It is not a perspective dolly —\n * under perspective `1/size` would be the straight line and it plainly is not.\n *\n * **The field drifts under it.** Take the scale out and each tile's path becomes\n * a straight world-line to within a pixel: a constant velocity, ~80 stage px per\n * second, in its own direction. Model it as a static field and you are 32px out;\n * model the drift and six of the seven fitted tiles land within **0.3–1.0px per\n * frame over their whole path**.\n *\n * **The words do not fade in.** Measure the ink and it is conserved from the\n * first frame a word exists. What resolves is 1.216× of scale and 4.3px of blur,\n * in 140ms, one word every 150ms — so each lands exactly as the next starts. The\n * line is centre-set, so the words already placed slide left to make room.\n *\n * ## What is not measured\n *\n * The labels and the paint. The reference is a specific product showing off a\n * specific set of other people's logos; none of that belongs in a component that\n * ships to strangers (design-system rule 5). The default field is the stack a\n * snapcn component actually drops into, drawn as wordmark tiles rather than\n * borrowed marks, and every tile — glyph, label, colour, position, drift, entry\n * — is a prop.\n */\nexport function LogoDrift({\n  headline = \"Built for the stack you already use.\",\n  tiles = SNAPCN_STACK,\n  pullback = 0.1762,\n  tileSpeed = 1,\n  tileScale = 1,\n  tileFade = 0.4,\n  tileRadius = 0.02,\n  wordAt = 0.017,\n  wordStagger = 0.15,\n  wordDuration = WORD_DUR,\n  wordScale = WORD_SCALE,\n  wordBlur = WORD_BLUR,\n  exitAt = 3.13,\n  exitDuration = EXIT_DUR,\n  exitScale = EXIT_SCALE,\n  exitBlur = EXIT_BLUR,\n  fontSize = 20,\n  fontWeight = 500,\n  glyphScale = 0.34,\n  glow = true,\n  glowColor,\n  glowOpacity = 0.07,\n  accentColor,\n  theme,\n  mode,\n  fontFamily,\n  speed = 1,\n}: LogoDriftProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps } = useVideoConfig();\n  const t = useSnapCnTheme(theme, mode);\n  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;\n  const accent = accentColor ?? t.primary;\n  const wash = glowColor ?? accent;\n\n  const now = (frame * speed) / fps;\n  const stageScale = Math.min(width / REF_W, height / REF_H);\n\n  const words = headline.split(/\\s+/).filter(Boolean);\n  const isRendering = getRemotionEnvironment().isRendering;\n  const willChange = isRendering ? undefined : (\"transform, filter\" as const);\n\n  return (\n    <AbsoluteFill style={{ background: t.background }}>\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          top: \"50%\",\n          width: REF_W,\n          height: REF_H,\n          transform: `translate(-50%, -50%) scale(${stageScale})`,\n          overflow: \"hidden\",\n          background: t.background,\n        }}\n      >\n        {/* The wash. Static — it does not move, breathe or follow the pull-back\n            on the reference, and a glow that animates under a headline reads as\n            a mistake rather than as light. Painted from the installer's accent\n            at a few percent, because at this strength it is a tint, not a shape:\n            the peak is seven luminance levels off the page. */}\n        {glow ? (\n          <div\n            style={{\n              position: \"absolute\",\n              left: 0,\n              top: 0,\n              width: REF_W,\n              height: REF_H,\n              background: `radial-gradient(${0.45 * REF_W}px ${0.34 * REF_H}px at 50% 49%, ${withAlpha(wash, glowOpacity)} 0%, transparent 74%)`,\n            }}\n          />\n        ) : null}\n\n        {/* ---- The field. Each tile is one object to Remotion Studio (`Item`),\n            numbered by its place in `tiles`. */}\n        {tiles.map((tile, i) => {\n          const p = placeTile(tile, now, {\n            rate: pullback,\n            fade: tileFade,\n            speed: tileSpeed,\n            scale: tileScale,\n          });\n          if (p.opacity <= 0.002 || p.size < 1) return null;\n          const fg = tile.color ?? t.background;\n          return (\n            <Item key={`${tile.glyph}-${tile.x}-${tile.y}`} index={i}>\n              <div\n                style={{\n                  position: \"absolute\",\n                  left: p.left,\n                  top: p.top,\n                  width: p.size,\n                  height: p.size,\n                  borderRadius: p.size * (tile.radius ?? tileRadius),\n                  background: tile.background,\n                  opacity: p.opacity,\n                  display: \"flex\",\n                  flexDirection: \"column\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  gap: p.size * 0.04,\n                  color: fg,\n                  fontFamily: face,\n                  overflow: \"hidden\",\n                  willChange,\n                }}\n              >\n                <span\n                  style={{\n                    fontSize: p.size * glyphScale,\n                    fontWeight: 600,\n                    lineHeight: 1,\n                    letterSpacing: \"-0.03em\",\n                    textRendering: \"geometricPrecision\",\n                  }}\n                >\n                  {tile.glyph}\n                </span>\n                {tile.label && p.size > 74 ? (\n                  <span\n                    style={{\n                      fontSize: p.size * 0.1,\n                      fontWeight: 500,\n                      lineHeight: 1,\n                      opacity: 0.72,\n                      textRendering: \"geometricPrecision\",\n                    }}\n                  >\n                    {tile.label}\n                  </span>\n                ) : null}\n              </div>\n            </Item>\n          );\n        })}\n\n        {/* ---- The headline. Above the field: a wall of logos that crosses the\n            one line the viewer is meant to read is a wall of logos that has won\n            an argument it should not have been in. */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: 0,\n            top: 0,\n            width: REF_W,\n            height: REF_H,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"baseline\",\n              justifyContent: \"center\",\n              gap: fontSize * 0.28,\n              fontFamily: face,\n              fontSize,\n              fontWeight,\n              lineHeight: 1.2,\n              letterSpacing: \"-0.012em\",\n              color: t.foreground,\n              whiteSpace: \"pre\",\n              textRendering: \"geometricPrecision\",\n            }}\n          >\n            {words.map((word, i) => {\n              const w = wordState(now, i, {\n                at: wordAt,\n                stagger: wordStagger,\n                dur: wordDuration,\n                scale: wordScale,\n                blur: wordBlur,\n                exitAt,\n                exitDur: exitDuration,\n                exitScale,\n                exitBlur,\n              });\n              return (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: words in a headline repeat, so the index is what makes each key unique\n                  key={`${word}-${i}`}\n                  style={{\n                    display: \"inline-block\",\n                    opacity: w.opacity,\n                    transform: `scale(${w.scale})`,\n                    filter: w.blur > 0.02 ? `blur(${w.blur}px)` : undefined,\n                    willChange,\n                  }}\n                >\n                  {word}\n                </span>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n}\n\n/** Kept for callers that want the accent-tinted tile default without a theme. */\nexport const driftTileTint = (t: SnapCnTheme, amount: number): string =>\n  mixOklch(t.card, t.primary, amount);\n",
      "type": "registry:component",
      "target": "components/snap-cn/logo-drift.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/logos/logo-drift?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. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
