Catalog

Edit Props (live)

IMAGE
TEXT
TEXT
config
config
config
config
config
config
config

Source

311 lines
import React from "react";
import {
  AbsoluteFill,
  Img,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  spring,
  staticFile,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";

loadInter("normal", { weights: ["400", "700", "900"] });

interface PhoneMockupProps {
  screenshotSrc: string;
  // Multiple phones support
  extraPhones?: { src: string; offsetX: number; offsetY: number; rotation?: number }[];
  title?: string;
  subtitle?: string;
  backgroundColor?: string;
  accentColor?: string;
  // Scroll the content inside the phone screen
  scrollScreen?: boolean;
}

const FONT = "'Inter', sans-serif";

// CSS-only iPhone bezel approximation
const PhoneBody: React.FC<{
  src: string;
  w?: number;
  scrollScreen?: boolean;
  scrollAmt?: number; // 0..1 normalized scroll progress
  statusBar?: boolean; // show a faux iOS status bar on top
}> = ({ src, w = 420, scrollScreen = false, scrollAmt = 0, statusBar = true }) => {
  const h = w * 2.17; // iPhone aspect
  const screenPad = w * 0.025;
  const screenRadius = w * 0.13;

  return (
    <div
      style={{
        width: w,
        height: h,
        borderRadius: w * 0.16,
        background: "linear-gradient(145deg, #1a1a1c, #2a2a2e)",
        padding: screenPad,
        boxShadow:
          "0 35px 80px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.18), 0 0 0 2px rgba(255,255,255,0.05)",
        position: "relative",
      }}
    >
      {/* Screen — always full-cover so the frame never shows empty space.
          objectPosition animates vertically to simulate scrolling through
          the content. Works for both portrait and landscape sources. */}
      <div
        style={{
          width: "100%",
          height: "100%",
          borderRadius: screenRadius,
          overflow: "hidden",
          background: "#000",
          position: "relative",
        }}
      >
        <Img
          src={src.startsWith("http") ? src : staticFile(src)}
          style={{
            width: "100%",
            height: "100%",
            objectFit: "cover",
            objectPosition: scrollScreen
              ? `center ${10 + scrollAmt * 80}%`
              : "center center",
            display: "block",
          }}
        />
        {/* Faux iOS status bar gradient at top — just enough to feel like an app */}
        {statusBar && (
          <>
            <div
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                right: 0,
                height: w * 0.11,
                background: "linear-gradient(to bottom, rgba(0,0,0,0.35), transparent)",
                pointerEvents: "none",
              }}
            />
            <div
              style={{
                position: "absolute",
                top: w * 0.03,
                right: w * 0.12,
                fontSize: w * 0.042,
                fontWeight: 700,
                color: "#fff",
                fontFamily: "'Inter', sans-serif",
                textShadow: "0 1px 2px rgba(0,0,0,0.4)",
                letterSpacing: -0.3,
              }}
            >
              9:41
            </div>
          </>
        )}
      </div>
      {/* Dynamic Island / notch */}
      <div
        style={{
          position: "absolute",
          top: w * 0.05,
          left: "50%",
          transform: "translateX(-50%)",
          width: w * 0.26,
          height: w * 0.07,
          borderRadius: w * 0.035,
          background: "#000",
          pointerEvents: "none",
        }}
      />
      {/* Side buttons (left volume + lock) */}
      <div
        style={{
          position: "absolute",
          left: -3,
          top: w * 0.35,
          width: 3,
          height: w * 0.18,
          background: "#2a2a2e",
          borderRadius: 2,
        }}
      />
      <div
        style={{
          position: "absolute",
          right: -3,
          top: w * 0.45,
          width: 3,
          height: w * 0.22,
          background: "#2a2a2e",
          borderRadius: 2,
        }}
      />
    </div>
  );
};

export const PhoneMockup: React.FC<PhoneMockupProps> = ({
  screenshotSrc,
  extraPhones = [],
  title,
  subtitle,
  backgroundColor = "#0a0612",
  accentColor = "#b89eff",
  scrollScreen = true,
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();
  const t = frame / fps;

  // Center phone springs in
  const mainSpring = spring({ frame, fps, config: { mass: 1, damping: 14, stiffness: 110 } });
  const mainY = interpolate(mainSpring, [0, 1], [180, 0]);
  const mainScale = interpolate(mainSpring, [0, 1], [0.85, 1]);
  const mainOp = interpolate(frame, [0, 12], [0, 1], { extrapolateRight: "clamp" });

  // Gentle float (sinusoidal)
  const floatY = Math.sin(t * 1.2) * 10;
  const floatRot = Math.sin(t * 0.9 + 1.3) * 0.8;

  // Screenshot scroll — slow pan top to bottom
  const scrollAmt = interpolate(frame, [15, durationInFrames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });

  // Title reveal
  const titleOp = interpolate(frame, [25, 45], [0, 1], { extrapolateRight: "clamp" });
  const titleY = interpolate(frame, [25, 45], [20, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor,
        overflow: "hidden",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {/* Accent gradient backdrop */}
      <div
        style={{
          position: "absolute",
          top: "30%",
          left: "50%",
          width: 1200,
          height: 1200,
          transform: "translate(-50%, -30%)",
          background: `radial-gradient(circle, ${accentColor}44 0%, ${accentColor}00 60%)`,
          pointerEvents: "none",
          filter: "blur(40px)",
        }}
      />

      {/* Title above phone */}
      {title && (
        <div
          style={{
            position: "absolute",
            top: 140,
            left: 0,
            right: 0,
            textAlign: "center",
            fontFamily: FONT,
            fontSize: 60,
            fontWeight: 900,
            letterSpacing: -1.8,
            color: "#fff",
            lineHeight: 1.08,
            opacity: titleOp,
            transform: `translateY(${titleY}px)`,
            padding: "0 100px",
            zIndex: 5,
          }}
        >
          {title}
        </div>
      )}
      {subtitle && (
        <div
          style={{
            position: "absolute",
            top: 280,
            left: 0,
            right: 0,
            textAlign: "center",
            fontFamily: FONT,
            fontSize: 26,
            fontWeight: 500,
            color: "rgba(255,255,255,0.72)",
            letterSpacing: 2,
            textTransform: "uppercase",
            opacity: titleOp,
            transform: `translateY(${titleY}px)`,
            zIndex: 5,
          }}
        >
          {subtitle}
        </div>
      )}

      {/* Extra phones (behind, staggered) */}
      {extraPhones.map((p, i) => {
        const phaseSpring = spring({
          frame: frame - 6 - i * 4,
          fps,
          config: { mass: 1.1, damping: 13 },
        });
        const px = interpolate(phaseSpring, [0, 1], [p.offsetX - 80, p.offsetX]);
        const py = interpolate(phaseSpring, [0, 1], [p.offsetY + 60, p.offsetY]);
        const pop = interpolate(frame, [6 + i * 4, 22 + i * 4], [0, 0.9], {
          extrapolateRight: "clamp",
        });
        const pFloatY = Math.sin(t * 1.1 + i * 1.3) * 8;
        const pFloatR = Math.sin(t * 0.8 + i * 0.7) * 1.2;
        const rot = (p.rotation || 0) + pFloatR;
        return (
          <div
            key={i}
            style={{
              position: "absolute",
              top: "50%",
              left: "50%",
              transform: `translate(${px}px, ${py + pFloatY}px) rotate(${rot}deg) scale(0.72)`,
              opacity: pop,
              zIndex: 1,
            }}
          >
            <PhoneBody src={p.src} w={380} scrollScreen={scrollScreen} scrollAmt={scrollAmt} />
          </div>
        );
      })}

      {/* Center phone */}
      <div
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          transform: `translate(-50%, calc(-50% + ${mainY + floatY}px)) scale(${mainScale}) rotate(${floatRot}deg)`,
          opacity: mainOp,
          zIndex: 3,
        }}
      >
        <PhoneBody src={screenshotSrc} w={520} scrollScreen={scrollScreen} scrollAmt={scrollAmt} />
      </div>
    </AbsoluteFill>
  );
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: PhoneMockup
// 10 props detected · 3 port(s)

Phone Mockup

phone_mockup

Phone Mockup — Andy's hand-crafted Remotion scene. 10 props. screenshotSrc:IMAGE, title:TEXT, subtitle:TEXT.

Workflow Inputs (3)

  • Screenshot Src
    screenshotSrc
    IMAGERequired
  • Title
    title
    TEXT
  • Subtitle
    subtitle
    TEXT

Config Fields (7)

  • offsetX
  • offsetY
  • rotation
  • accentColor
  • extraPhones
  • scrollScreen
  • backgroundColor

Meta

Updated
4/21/2026