{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "phone-stack",
  "title": "Phone Stack",
  "description": "Stacked phone screens with video. Phone screens rise out of a blur from below one after another, and each new one pushes the stack back: the older screens shrink and step up so only their tops peek out above the newest. Every screen plays its own clip under an iOS status bar. Proportions measured off a reference recording.",
  "dependencies": [
    "remotion"
  ],
  "files": [
    {
      "path": "registry/snap-cn/phone-stack/index.tsx",
      "content": "// Phone Stack · snapcn — https://snapcn.dev/docs/screens/phone-stack\n\"use client\";\n\nimport type { CSSProperties } from \"react\";\nimport {\n  Easing,\n  getRemotionEnvironment,\n  interpolate,\n  OffthreadVideo,\n  Sequence,\n  staticFile,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\n/**\n * A \"phone stack\": phone screens rise out of a blur from below, one after\n * another, and each new one pushes the stack back — the older screens shrink\n * and step up so only their tops peek out above the newest, which settles in\n * front. Every screen plays its own clip under an iOS status bar.\n *\n * Proportions were measured off a two-screen reference recording (716 × 398,\n * 30fps) by tracking each screen's Dynamic Island: a screen one step back is\n * 0.83× the size and its top sits 24.6 reference px higher. The rise is the\n * reference's long ease-out. Lengths are reference pixels (`REF_W` wide) and\n * scale with the frame.\n *\n * Like `announce-title` it paints a world rather than a surface — a cream page\n * with a lavender glow — so it takes no `theme`/`mode`; its colours are props.\n */\n\nexport interface PhoneStackProps {\n  /**\n   * One clip per screen, in the order they arrive. Video URLs play muted;\n   * images show as stills. Absolute URLs or paths under `public/`.\n   */\n  videos?: string[];\n  /** Frames between one screen arriving and the next. */\n  stagger?: number;\n  /** The page. */\n  background?: string;\n  /** The glow the stack rises out of. */\n  glowColor?: string;\n  /** The status bar's ink over the clips. */\n  statusColor?: string;\n  /** The time in the status bar. */\n  time?: string;\n  speed?: number;\n  className?: string;\n}\n\n/** Creator-style clips, 12s each. Absolute, because a root-relative default\n *  becomes `staticFile()` in a render and 404s in the project this file was\n *  just copied into. */\nconst DEFAULT_VIDEOS = [2, 3, 4, 5, 6].map(\n  (n) => `https://media.snapcn.dev/demos/stock/wall-${n}.mp4`,\n);\n\n// ─── Geometry, in reference pixels ────────────────────────────────────────────\nexport const REF_W = 716;\nconst REF_H = 398;\n/** The front screen at rest: width, top edge, centre line. */\nexport const SCREEN = { w: 240, top: 74, cx: 358, radius: 26 } as const;\n/** Each step back: the size it keeps, and how far its top climbs (at scale 1). */\nexport const STEP_SCALE = 0.83;\nexport const STEP_RISE = 24.6;\n/** Screens deeper than this fade out as the next one arrives. */\nconst DEPTH = 3;\n/** An arriving screen starts below the frame, larger, blurred and clear. */\nconst ENTER = { top: 400, scale: 1.12, blur: 9 } as const;\n\n// ─── Timeline (30fps frames) ──────────────────────────────────────────────────\n/** One arrival or one step back. */\nexport const MOVE = 26;\nconst RISE = Easing.bezier(0.16, 1, 0.3, 1);\n\n/** Where a screen sits `depth` steps back: scale and top edge. */\nfunction slot(depth: number) {\n  const scale = STEP_SCALE ** depth;\n  let top = SCREEN.top;\n  for (let d = 1; d <= depth; d++) top -= STEP_RISE * STEP_SCALE ** (d - 1);\n  return { scale, top };\n}\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  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\nconst isVideoSrc = (src: string) => /\\.(mp4|webm|mov|m4v)(?:[?#]|$)/i.test(src);\n\nconst cover: CSSProperties = {\n  position: \"absolute\",\n  inset: 0,\n  width: \"100%\",\n  height: \"100%\",\n  maxWidth: \"none\",\n  objectFit: \"cover\",\n};\n\n/** Signal, Wi-Fi and battery, drawn in `currentColor`. */\nfunction StatusIcons({ h }: { h: number }) {\n  return (\n    <svg\n      height={h}\n      viewBox=\"0 0 68 12\"\n      fill=\"currentColor\"\n      aria-hidden\n      style={{ display: \"block\" }}\n    >\n      <rect x=\"0\" y=\"8\" width=\"3\" height=\"4\" rx=\"1\" />\n      <rect x=\"5\" y=\"6\" width=\"3\" height=\"6\" rx=\"1\" />\n      <rect x=\"10\" y=\"3.5\" width=\"3\" height=\"8.5\" rx=\"1\" />\n      <rect x=\"15\" y=\"1\" width=\"3\" height=\"11\" rx=\"1\" />\n      <path d=\"M31 2.2c2.6 0 5 1 6.8 2.7l-1.3 1.4A7.9 7.9 0 0 0 31 4.1a7.9 7.9 0 0 0-5.5 2.2l-1.3-1.4A9.8 9.8 0 0 1 31 2.2Zm0 3.8c1.6 0 3 .6 4.1 1.6L33.8 9a3.9 3.9 0 0 0-5.6 0l-1.3-1.4A5.8 5.8 0 0 1 31 6Zm0 3.7 1.6 1.7L31 12l-1.6-1.6L31 9.7Z\" />\n      <rect\n        x=\"43.5\"\n        y=\"1.5\"\n        width=\"21\"\n        height=\"9\"\n        rx=\"2.6\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        opacity=\"0.45\"\n      />\n      <rect x=\"45.5\" y=\"3.5\" width=\"17\" height=\"5\" rx=\"1.3\" />\n      <rect x=\"65.5\" y=\"4.3\" width=\"1.6\" height=\"3.4\" rx=\"0.8\" opacity=\"0.45\" />\n    </svg>\n  );\n}\n\nexport function PhoneStack({\n  videos = DEFAULT_VIDEOS,\n  stagger = 14,\n  background = \"#f3f3ee\",\n  glowColor = \"#cdb4e6\",\n  statusColor = \"#ffffff\",\n  time = \"9:41\",\n  speed = 1,\n  className,\n}: PhoneStackProps) {\n  const { width, height, fps } = useVideoConfig();\n  const tick = (speed * 30) / fps;\n  const frame = useCurrentFrame() * tick;\n  const u = width / REF_W;\n  const dy = (height - REF_H * u) / 2;\n  const n = videos.length;\n\n  // How far a screen has come: 0 below the frame, 1 in front, 2 one step\n  // back, … — every arrival after it adds one, on the same eased curve.\n  const progress = (i: number) => {\n    let p = 0;\n    for (let k = i; k < n; k++) {\n      p += interpolate(frame, [k * stagger, k * stagger + MOVE], [0, 1], {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n        easing: RISE,\n      });\n    }\n    return p;\n  };\n\n  return (\n    <div\n      className={className}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor: background,\n      }}\n    >\n      {/* The glow the stack rises out of. */}\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          bottom: -0.55 * height,\n          width: 1.5 * width,\n          height: 1.1 * height,\n          transform: \"translateX(-50%)\",\n          borderRadius: \"50%\",\n          background: glowColor,\n          opacity: 0.55,\n          filter: `blur(${70 * u}px)`,\n        }}\n      />\n\n      {videos.map((src, i) => {\n        const p = progress(i);\n        if (p <= 0) return null;\n        // 0 → 1: the rise. Past 1: steps back, one per later arrival.\n        const enter = Math.min(p, 1);\n        const depth = Math.max(p - 1, 0);\n        const lo = slot(Math.floor(depth));\n        const hi = slot(Math.floor(depth) + 1);\n        const f = depth - Math.floor(depth);\n        const scale =\n          enter < 1\n            ? ENTER.scale + (1 - ENTER.scale) * enter\n            : lo.scale + (hi.scale - lo.scale) * f;\n        const top =\n          enter < 1\n            ? ENTER.top + (SCREEN.top - ENTER.top) * enter\n            : lo.top + (hi.top - lo.top) * f;\n        const blur = ENTER.blur * (1 - enter);\n        const opacity =\n          Math.min(1, enter * 2.5) *\n          interpolate(depth, [DEPTH - 1, DEPTH], [1, 0], {\n            extrapolateLeft: \"clamp\",\n            extrapolateRight: \"clamp\",\n          });\n        if (opacity <= 0) return null;\n\n        const w = SCREEN.w * scale * u;\n        const h = w * (852 / 393);\n        const island = { w: 0.32 * w, h: 0.094 * w, top: 0.028 * w };\n        const resolved = resolveSrc(src);\n        return (\n          <div\n            key={`${i}-${src}`}\n            style={{\n              position: \"absolute\",\n              left: SCREEN.cx * u - w / 2,\n              top: dy + top * u,\n              width: w,\n              height: h,\n              borderRadius: SCREEN.radius * scale * u,\n              overflow: \"hidden\",\n              backgroundColor: \"#ffffff\",\n              boxShadow: `0 ${8 * u}px ${28 * u}px rgba(70, 50, 110, 0.14), 0 0 0 ${0.6 * u}px rgba(255, 255, 255, 0.9)`,\n              opacity,\n              filter: blur > 0.05 ? `blur(${blur * u}px)` : undefined,\n              zIndex: i,\n            }}\n          >\n            {isVideoSrc(src) ? (\n              <Sequence from={Math.floor((i * stagger) / tick)} layout=\"none\">\n                <OffthreadVideo src={resolved} muted style={cover} />\n              </Sequence>\n            ) : (\n              // biome-ignore lint/performance/noImgElement: Remotion still\n              <img src={resolved} alt=\"\" style={cover} />\n            )}\n            {/* Status bar and Dynamic Island over the clip. */}\n            <div\n              style={{\n                position: \"absolute\",\n                left: 0,\n                right: 0,\n                top: 0,\n                height: island.top * 2 + island.h,\n                background:\n                  \"linear-gradient(to bottom, rgba(0,0,0,0.28), rgba(0,0,0,0))\",\n              }}\n            />\n            <div\n              style={{\n                position: \"absolute\",\n                left: \"50%\",\n                top: island.top,\n                width: island.w,\n                height: island.h,\n                transform: \"translateX(-50%)\",\n                borderRadius: island.h / 2,\n                background: \"#000000\",\n              }}\n            />\n            <div\n              style={{\n                position: \"absolute\",\n                left: 0.09 * w,\n                top: island.top,\n                height: island.h,\n                display: \"flex\",\n                alignItems: \"center\",\n                color: statusColor,\n                fontFamily:\n                  '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Segoe UI\", sans-serif',\n                fontWeight: 600,\n                fontSize: 0.045 * w,\n              }}\n            >\n              {time}\n            </div>\n            <div\n              style={{\n                position: \"absolute\",\n                right: 0.07 * w,\n                top: island.top,\n                height: island.h,\n                display: \"flex\",\n                alignItems: \"center\",\n                color: statusColor,\n              }}\n            >\n              <StatusIcons h={0.034 * w} />\n            </div>\n          </div>\n        );\n      })}\n\n      {/* The stack's foot fades into the page and the glow. */}\n      <div\n        style={{\n          position: \"absolute\",\n          left: 0,\n          right: 0,\n          bottom: 0,\n          height: 0.38 * height,\n          background: `linear-gradient(to bottom, transparent 0%, color-mix(in srgb, ${background} 55%, transparent) 60%, ${background} 100%)`,\n          zIndex: 100,\n        }}\n      />\n      <div\n        style={{\n          position: \"absolute\",\n          left: \"50%\",\n          bottom: -0.45 * height,\n          width: 1.1 * width,\n          height: 0.7 * height,\n          transform: \"translateX(-50%)\",\n          borderRadius: \"50%\",\n          background: glowColor,\n          opacity: 0.35,\n          filter: `blur(${60 * u}px)`,\n          zIndex: 101,\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/snap-cn/phone-stack.tsx"
    }
  ],
  "docs": "Docs and props: https://snapcn.dev/docs/screens/phone-stack?ref=cli — pass one clip per screen as videos; the page and glow are props. — 82 Pro components too — charts, launch scenes, device shots: https://snapcn.dev/docs/pricing?ref=cli-docs",
  "type": "registry:component"
}
