← Catalog
Edit Props (live)
IMAGE
TEXT
TEXT
config
config
config
config
config
config
config
Source
311 linesimport 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_mockupPhone Mockup — Andy's hand-crafted Remotion scene. 10 props. screenshotSrc:IMAGE, title:TEXT, subtitle:TEXT.
Workflow Inputs (3)
- Screenshot Src
screenshotSrcIMAGERequired - Title
titleTEXT - Subtitle
subtitleTEXT
Config Fields (7)
offsetXoffsetYrotationaccentColorextraPhonesscrollScreenbackgroundColor
Meta
- Updated
- 4/21/2026