{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbit-gallery",
  "title": "Orbit Gallery",
  "description": "An image-universe hero: a continuous stream of photos flows along an Archimedean spiral (a vortex) from the frame edges into the center — evenly spaced by arc length, shrinking and rotating to follow the coil, with the title held in the clear middle. A slow, cinematic opener for launch and editorial videos.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "https://snapcn.dev/r/snap-cn-ui.json"
  ],
  "files": [
    {
      "path": "registry/snap-cn/orbit-gallery/index.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  Easing,\n  Img,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\nimport {\n  mixOklch,\n  type SnapCnTheme,\n  useSnapCnTheme,\n  withAlpha,\n} from \"@/lib/snap-cn-ui\";\n\nconst TWO_PI = Math.PI * 2;\n/** Lookup resolution for the arc-length reparameterization table. */\nconst ARC_SAMPLES = 1024;\n\nexport interface OrbitGalleryProps {\n  title?: string;\n  subtitle?: string;\n  /** Pill CTA under the subtitle. Empty string hides it. */\n  buttonLabel?: string;\n  /**\n   * Card artwork URLs, cycled along the spiral stream. A URL that fails to load\n   * falls back to a gradient tile rather than breaking playback. Pass `[]` to\n   * render self-contained editorial gradient tiles (no network).\n   */\n  images?: string[];\n  /** How many times the Archimedean spiral winds from the edge to the center. */\n  turns?: number;\n  /** Arc spacing between cards along the stream (smaller = denser). */\n  spacing?: number;\n  /** Radius scale — larger pushes the outer coils off-frame, same coil gaps. */\n  spread?: number;\n  /** How much cards shrink toward the center (0 = uniform). */\n  sizeAttenuation?: number;\n  /** Card long-edge in px at the outer radius, on the reference 600px canvas. */\n  imageSize?: number;\n  /** Card width / height (portrait < 1). */\n  cardAspect?: number;\n  /** Percent of the path over which a card fades in at the outer end. */\n  fadeIn?: number;\n  /** Percent of the path over which a card fades out at the center end. */\n  fadeOut?: number;\n  /** Corner rounding, matching the reference's 0–20 scale (0 = sharp). */\n  cornerRadius?: number;\n  /** Seconds for the stream to flow one full turnover into the center. */\n  orbitSeconds?: number;\n  /** Overrides the design system's `background`. */\n  background?: string;\n  /** Overrides the design system's `foreground`. */\n  textColor?: string;\n  /** Design-system token overrides. */\n  theme?: Partial<SnapCnTheme>;\n  /** Defaults to `\"dark\"` — the orbit is a cinematic stage, lit for dark. */\n  mode?: \"light\" | \"dark\";\n  /** Tiny captions pinned to the bottom edge. Empty strings hide them. */\n  footerLeft?: string;\n  footerCenter?: string;\n  footerRight?: string;\n  speed?: number;\n  className?: string;\n}\n\nexport const SAMPLE_IMAGES = Array.from(\n  { length: 16 },\n  (_, i) => `https://picsum.photos/seed/snap-orbit-${i + 1}/440/560`,\n);\n\n/** Editorial two-stop gradients used for tiles without a (working) photo. */\nexport const PLACEHOLDER_FILLS = [\n  \"linear-gradient(135deg, #2b5fd9, #14306e)\",\n  \"linear-gradient(135deg, #c96f2f, #7a3c12)\",\n  \"linear-gradient(135deg, #e8e4da, #b6ad99)\",\n  \"linear-gradient(135deg, #c8332b, #6e1511)\",\n  \"linear-gradient(135deg, #b9a0d0, #6f5590)\",\n  \"linear-gradient(135deg, #4c6b3c, #24361a)\",\n  \"linear-gradient(135deg, #1f4dd8, #0d2262)\",\n  \"linear-gradient(135deg, #d9d2c5, #a09684)\",\n];\n\nconst FONT_STACK =\n  \"var(--font-geist-sans), Inter, -apple-system, BlinkMacSystemFont, sans-serif\";\n\n/**\n * Archimedean spiral point at parameter `n` in [0, 1]: n=0 is the outer edge\n * (radius R), n=1 is the center (radius 0). Because the radius falls off\n * LINEARLY, every coil is separated by the same radial gap (R / turns) — the\n * even \"circle to circle\" spacing. `y` is flipped so the spiral reads clockwise\n * from the top like the reference.\n */\nexport function archimedeanPoint(\n  n: number,\n  R: number,\n  turns: number,\n): { x: number; y: number } {\n  const ang = n * turns * TWO_PI;\n  const rad = R * (1 - n);\n  return { x: rad * Math.cos(ang), y: -rad * Math.sin(ang) };\n}\n\n/**\n * Build the arc-length reparameterization table for a spiral of `turns` turns.\n * Sampling the path by even parameter would bunch cards near the center (where\n * the coils are short); sampling by even ARC length spaces them evenly. The\n * spiral's shape is radius-independent, so the table is built once at R=1 and\n * returns, for each even arc fraction, the spiral parameter `n` that lands\n * there.\n */\nexport function buildArcTable(turns: number, samples = 2000): Float64Array {\n  const cum = new Float64Array(samples + 1);\n  let prev = archimedeanPoint(0, 1, turns);\n  for (let k = 1; k <= samples; k++) {\n    const pt = archimedeanPoint(k / samples, 1, turns);\n    cum[k] = cum[k - 1] + Math.hypot(pt.x - prev.x, pt.y - prev.y);\n    prev = pt;\n  }\n  const total = cum[samples] || 1;\n\n  const nForArc = new Float64Array(ARC_SAMPLES + 1);\n  let j = 0;\n  for (let a = 0; a <= ARC_SAMPLES; a++) {\n    const target = (a / ARC_SAMPLES) * total;\n    while (j < samples && cum[j + 1] < target) j++;\n    const seg = cum[j + 1] - cum[j];\n    const f2 = seg > 0 ? (target - cum[j]) / seg : 0;\n    nForArc[a] = (j + f2) / samples;\n  }\n  return nForArc;\n}\n\n/** Map an arc fraction `s` in [0, 1) to the spiral parameter `n` (interpolated\n * so motion stays smooth, never quantized into visible steps). */\nexport function arcToN(s: number, nForArc: Float64Array): number {\n  const x = Math.max(0, Math.min(ARC_SAMPLES, s * ARC_SAMPLES));\n  const i = Math.floor(x);\n  const a = nForArc[i];\n  const b = nForArc[Math.min(i + 1, ARC_SAMPLES)];\n  return a + (b - a) * (x - i);\n}\n\n/** Radius scale for the spiral: R = 0.48·min · (1 + (spread − 1)·0.18). */\nexport function ringRadius(minDim: number, spread: number): number {\n  return 0.48 * minDim * (1 + (spread - 1) * 0.18);\n}\n\n/** Card size multiplier from its distance to the center — smaller toward the\n * middle. `dist/R` clamped to 1, raised to `sizeAttenuation/2`. */\nexport function sizeScale(\n  dist: number,\n  R: number,\n  sizeAttenuation: number,\n): number {\n  return sizeAttenuation > 0\n    ? Math.min(dist / R, 1) ** (sizeAttenuation * 0.5)\n    : 1;\n}\n\n/**\n * One card face: the editorial gradient is always painted as the base layer,\n * and a photo (when provided) overlays it. A failed load flips to `errored` so\n * the gradient shows through — crucially, the `onError` handler also stops\n * Remotion's <Img> from calling cancelRender(), which would otherwise halt the\n * whole player when an external image is blocked or slow to fail. */\nfunction OrbitTile({ src, fill }: { src: string | undefined; fill: string }) {\n  const [errored, setErrored] = useState(false);\n  return (\n    <>\n      <div style={{ position: \"absolute\", inset: 0, backgroundImage: fill }} />\n      {src && !errored ? (\n        <Img\n          src={src}\n          crossOrigin=\"anonymous\"\n          onError={() => setErrored(true)}\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            width: \"100%\",\n            height: \"100%\",\n            objectFit: \"cover\",\n          }}\n        />\n      ) : null}\n    </>\n  );\n}\n\nexport function OrbitGallery({\n  title = \"A whole new universe\",\n  subtitle = \"Article — The Design Process\",\n  buttonLabel = \"Explore\",\n  images,\n  turns = 3,\n  spacing = 5,\n  spread = 7,\n  sizeAttenuation = 2,\n  imageSize = 172,\n  cardAspect = 0.78,\n  fadeIn = 20,\n  fadeOut = 0,\n  cornerRadius = 5,\n  orbitSeconds = 40,\n  background,\n  textColor,\n  theme,\n  mode,\n  footerLeft = \"\",\n  footerCenter = \"\",\n  footerRight = \"\",\n  speed = 1,\n  className,\n}: OrbitGalleryProps) {\n  const frame = useCurrentFrame();\n  const { width, height, fps, durationInFrames } = useVideoConfig();\n  const tokens = useSnapCnTheme(theme, mode ?? \"dark\");\n  const stage = background ?? tokens.background;\n  const ink = textColor ?? tokens.foreground;\n  const t = frame * speed;\n\n  const minDim = Math.min(width, height);\n  const R = ringRadius(minDim, spread);\n  const baseSize = imageSize * (minDim / 600);\n  const artwork = images ?? SAMPLE_IMAGES;\n  const nImgs = artwork.length || 1;\n\n  // Arc-length table (shape-only, so it depends on `turns` alone).\n  const nForArc = useMemo(() => buildArcTable(turns), [turns]);\n\n  const stepFrac = Math.max(0.005, (spacing * 0.5) / 100);\n  const slots = Math.min(400, Math.ceil(1 / stepFrac) + 2);\n\n  // Seamless inward flow: advance the stream by a whole number of card-steps\n  // over the composition so the loop has no visible jump.\n  const stepsPerLoop = Math.max(\n    1,\n    Math.round(durationInFrames / fps / orbitSeconds / stepFrac),\n  );\n  const base =\n    ((((t / durationInFrames) * stepsPerLoop * stepFrac) % 1) + 1) % 1;\n\n  const cards = [];\n  for (let i = 0; i < slots; i++) {\n    const s = (((base + i * stepFrac) % 1) + 1) % 1;\n    const n = arcToN(s, nForArc);\n    cards.push({ key: i, tt: s * 100, n, imgIdx: i % nImgs });\n  }\n  // Draw outer coils first, center cards last (on top), like the reference.\n  cards.sort((a, b) => a.n - b.n);\n\n  const intro = interpolate(t, [0, 16], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n\n  const titleSize = Math.min(width * 0.066, height * 0.11);\n\n  return (\n    <AbsoluteFill\n      className={className}\n      style={{ backgroundColor: stage, fontFamily: FONT_STACK }}\n    >\n      {cards.map((card) => {\n        const p = archimedeanPoint(card.n, R, turns);\n        const dist = Math.hypot(p.x, p.y);\n\n        let opacity = 1;\n        if (card.tt < fadeIn) opacity = card.tt / fadeIn;\n        else if (fadeOut > 0 && card.tt > 100 - fadeOut)\n          opacity = (100 - card.tt) / fadeOut;\n        if (opacity < 0.01) return null;\n\n        const scale = sizeScale(dist, R, sizeAttenuation);\n        if (scale < 0.002) return null;\n\n        // Tangent of the spiral at this point → the card's rotation.\n        const p2 = archimedeanPoint(Math.min(card.n + 0.001, 1), R, turns);\n        const angleDeg = (Math.atan2(p2.y - p.y, p2.x - p.x) * 180) / Math.PI;\n\n        // Long edge = baseSize·scale; the short edge follows cardAspect.\n        const ch = baseSize * scale;\n        const cw = ch * cardAspect;\n        const rad = (cornerRadius / 20) * (Math.min(cw, ch) / 2);\n\n        // Only the few tiny cards passing right behind the title get a light\n        // blur (a cheap per-element filter on a handful of small cards — nothing\n        // like the frame-wide backdrop blur that stalled the player). Everything\n        // in the visible outer band stays sharp.\n        const centerBlurR = minDim * 0.16;\n        const blurPx = dist < centerBlurR ? (1 - dist / centerBlurR) * 4 : 0;\n\n        return (\n          <div\n            key={card.key}\n            style={{\n              position: \"absolute\",\n              left: \"50%\",\n              top: \"50%\",\n              width: cw,\n              height: ch,\n              marginLeft: -cw / 2,\n              marginTop: -ch / 2,\n              zIndex: Math.round(card.n * 8),\n              translate: `${p.x.toFixed(2)}px ${p.y.toFixed(2)}px`,\n              rotate: `${angleDeg.toFixed(2)}deg`,\n              opacity: opacity * intro,\n              filter:\n                blurPx > 0.15 ? `blur(${blurPx.toFixed(2)}px)` : undefined,\n              borderRadius: rad,\n              overflow: \"hidden\",\n              backgroundColor: mixOklch(stage, tokens.card, 0.35),\n              boxShadow: `0 ${(ch * 0.12).toFixed(1)}px ${(ch * 0.3).toFixed(1)}px ${withAlpha(\n                mixOklch(stage, \"#000\", 0.75),\n                0.4,\n              )}`,\n            }}\n          >\n            <OrbitTile\n              src={artwork.length ? artwork[card.imgIdx % nImgs] : undefined}\n              fill={PLACEHOLDER_FILLS[card.imgIdx % PLACEHOLDER_FILLS.length]}\n            />\n          </div>\n        );\n      })}\n\n      {/* Soft focus pool behind the lockup: a radial dark scrim so the title\n          reads. This is a plain gradient (no backdrop-filter) — the earlier\n          backdrop blur re-blurred every moving card each frame and froze the\n          player. The slight softness behind the text comes from the small\n          center cards' own light blur (see `centerBlur` in the card loop). */}\n      {title || subtitle || buttonLabel ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: \"50%\",\n            top: \"50%\",\n            width: width * 0.66,\n            height: height * 0.5,\n            marginLeft: -(width * 0.66) / 2,\n            marginTop: -(height * 0.5) / 2,\n            borderRadius: \"50%\",\n            backgroundImage: `radial-gradient(ellipse at center, ${withAlpha(\n              stage,\n              0.62,\n            )} 0%, ${withAlpha(stage, 0.34)} 42%, transparent 70%)`,\n            opacity: intro,\n            zIndex: 9,\n          }}\n        />\n      ) : null}\n\n      <AbsoluteFill\n        style={{\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          textAlign: \"center\",\n          zIndex: 10,\n        }}\n      >\n        {title ? (\n          <div\n            style={{\n              fontSize: titleSize,\n              fontWeight: 500,\n              letterSpacing: \"-0.015em\",\n              lineHeight: 1.08,\n              color: ink,\n              maxWidth: \"82%\",\n              opacity: interpolate(t, [8, 34], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              }),\n              translate: `0px ${interpolate(t, [8, 34], [titleSize * 0.4, 0], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n                easing: Easing.out(Easing.cubic),\n              })}px`,\n              filter: `blur(${interpolate(t, [8, 30], [8, 0], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              })}px)`,\n            }}\n          >\n            {title}\n          </div>\n        ) : null}\n\n        {subtitle ? (\n          <div\n            style={{\n              marginTop: titleSize * 0.42,\n              fontSize: titleSize * 0.26,\n              letterSpacing: \"0.02em\",\n              color: ink,\n              opacity:\n                0.65 *\n                interpolate(t, [16, 40], [0, 1], {\n                  extrapolateLeft: \"clamp\",\n                  extrapolateRight: \"clamp\",\n                }),\n              translate: `0px ${interpolate(\n                t,\n                [16, 40],\n                [titleSize * 0.25, 0],\n                {\n                  extrapolateLeft: \"clamp\",\n                  extrapolateRight: \"clamp\",\n                  easing: Easing.out(Easing.cubic),\n                },\n              )}px`,\n            }}\n          >\n            {subtitle}\n          </div>\n        ) : null}\n\n        {buttonLabel ? (\n          <div\n            style={{\n              marginTop: titleSize * 0.5,\n              padding: `${titleSize * 0.14}px ${titleSize * 0.34}px`,\n              borderRadius: 9999,\n              backgroundColor: withAlpha(ink, 0.14),\n              fontSize: titleSize * 0.23,\n              fontWeight: 500,\n              color: ink,\n              opacity: interpolate(t, [24, 44], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              }),\n              scale: String(\n                interpolate(t, [24, 48], [0.7, 1], {\n                  extrapolateLeft: \"clamp\",\n                  extrapolateRight: \"clamp\",\n                  easing: Easing.out(Easing.back(1.4)),\n                }),\n              ),\n            }}\n          >\n            {buttonLabel}\n          </div>\n        ) : null}\n      </AbsoluteFill>\n\n      {footerLeft || footerCenter || footerRight ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: width * 0.05,\n            right: width * 0.05,\n            bottom: height * 0.032,\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            fontSize: titleSize * 0.22,\n            color: ink,\n            opacity:\n              0.78 *\n              interpolate(t, [30, 50], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              }),\n            zIndex: 10,\n          }}\n        >\n          <span>{footerLeft}</span>\n          <span>{footerCenter}</span>\n          <span>{footerRight}</span>\n        </div>\n      ) : null}\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/orbit-gallery.tsx"
    }
  ],
  "type": "registry:component"
}