Phone Frame

Wraps any other composition inside a realistic iPhone mockup with a drop-in entrance.

Preview

Open editor

Usage

A multiplier component. Pick any other composition from the library and it renders inside a realistic iPhone mockup — Dynamic Island or classic notch — with a spring drop-in entrance.

The inner composition uses its own default props and renders at its native dimensions, scaled to fill the phone screen with cover fit. Pair this with chat compositions like MessageBubbles or WhatsAppMessages for a phone-on-screen reaction shot, or wrap a BrowserScroll recording to mockup a mobile app demo.

The composition is 1080×1920 (9:16) so it slots straight into TikTok, Reels, and Shorts pipelines.

Props

NameTypeDefault
device"dynamic-island" | "notch" | "plain""dynamic-island"
screenImagestring (url)""
innerCompositionIdstring (composition id)"MessageBubbles"
innerPropsRecord<string, unknown>

Composition

ID
PhoneFrame
Resolution
1920×1080
FPS
60
Duration
13.0s

Source

Copy or download the React source — drop it into your own Remotion project. The only runtime dependency is remotion.

"use client";
import { AbsoluteFill, Img, spring, useVideoConfig } from "remotion";
import { type ClipStyle, resolveClipStyle } from "../../clip-style";
import { componentsByIdBase as componentsById } from "../../componentsBase";
import { proxyExternalImg } from "../../proxy-image";
import { compositionsById } from "../../registry";
import { SafeAreaContext } from "../../safe-area";
import type { PhoneFitMode } from "../../schema";
import { snap } from "../../snap";
import { useDesignFrame } from "../../use-design-frame";

export type PhoneFrameProps = {
  device: "dynamic-island" | "notch" | "plain";
  innerCompositionId: string;
  screenImage: string;
  innerProps?: Record<string, unknown>;
  clipStyle?: ClipStyle;
};

const PHONE_W = 760;
const PHONE_H = 1560;
const BEZEL = 18;
const SCREEN_W = PHONE_W - BEZEL * 2;
const SCREEN_H = PHONE_H - BEZEL * 2;
const FRAME_RADIUS = 96;
const SCREEN_RADIUS = 78;
// Phone is portrait; canvas is 16:9. Scale the whole phone down so it fits
// the landscape frame with some vertical breathing room.
const PHONE_SCALE = 0.6;

// Safe-area insets so the inner composition doesn't render behind the
// dynamic island / notch (top) or the home indicator (bottom). The bottom
// dock matches the top device chrome so content sits in a symmetric column.
const DYNAMIC_ISLAND_TOP = 22;
const DYNAMIC_ISLAND_HEIGHT = 56;
const NOTCH_HEIGHT = 38;
const HOME_INDICATOR_BOTTOM = 14;
const HOME_INDICATOR_HEIGHT = 6;
const HOME_INDICATOR_INSET = HOME_INDICATOR_BOTTOM + HOME_INDICATOR_HEIGHT + 14; // 34
const SAFE_INSETS = {
  "dynamic-island": {
    top: DYNAMIC_ISLAND_TOP + DYNAMIC_ISLAND_HEIGHT + 20, // 98
    bottom: HOME_INDICATOR_INSET, // 34
  },
  notch: {
    top: NOTCH_HEIGHT + 20, // 58
    bottom: HOME_INDICATOR_INSET, // 34
  },
  // No top chrome — symmetric padding that matches the home-indicator dock.
  plain: {
    top: HOME_INDICATOR_INSET, // 34
    bottom: HOME_INDICATOR_INSET, // 34
  },
} as const;

export const PhoneFrame: React.FC<PhoneFrameProps> = ({
  device,
  innerCompositionId,
  screenImage,
  innerProps,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const { fps } = useVideoConfig();
  const s = resolveClipStyle(clipStyle, {
    background: "#ffffff",
    color: "#0f1014",
    fontFamily:
      "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif",
    accent: "#0a84ff",
  });

  const drop = spring({
    frame,
    fps,
    config: { damping: 14, stiffness: 110, mass: 0.85 },
  });
  const scale = (0.9 + drop * 0.1) * PHONE_SCALE;
  const ty = (1 - drop) * 60;

  const Component = componentsById[innerCompositionId];
  const innerInfo = compositionsById[innerCompositionId];

  return (
    <AbsoluteFill
      style={{
        background: s.background,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          width: PHONE_W,
          height: PHONE_H,
          background:
            "linear-gradient(150deg, #2a2a2e 0%, #0f0f12 50%, #1a1a1d 100%)",
          borderRadius: FRAME_RADIUS,
          padding: BEZEL,
          boxShadow:
            "0 60px 140px rgba(0,0,0,0.45), 0 0 0 2px rgba(255,255,255,0.06), inset 0 0 0 2px rgba(255,255,255,0.05)",
          opacity: drop,
          transform: `translate3d(0, ${snap(ty)}px, 0) scale(${scale})`,
          position: "relative",
        }}
      >
        <SideButton side="left" top={260} length={140} />
        <SideButton side="left" top={420} length={86} />
        <SideButton side="left" top={520} length={86} />
        <SideButton side="right" top={340} length={170} />

        <div
          style={{
            width: SCREEN_W,
            height: SCREEN_H,
            background: "#000",
            borderRadius: SCREEN_RADIUS,
            overflow: "hidden",
            position: "relative",
          }}
        >
          {screenImage ? (
            <Img
              src={proxyExternalImg(screenImage)}
              crossOrigin="anonymous"
              style={{
                width: "100%",
                height: "100%",
                objectFit: "cover",
                display: "block",
              }}
            />
          ) : Component && innerInfo ? (
            <ScaledScene
              Component={Component}
              compW={innerInfo.width}
              compH={innerInfo.height}
              defaultProps={innerInfo.defaultProps}
              overrideProps={innerProps}
              insetTop={SAFE_INSETS[device].top}
              insetBottom={SAFE_INSETS[device].bottom}
              fitMode={innerInfo.phoneFitMode ?? "width"}
            />
          ) : (
            <FallbackScreen />
          )}

          {device === "dynamic-island" ? (
            <DynamicIsland />
          ) : device === "notch" ? (
            <Notch />
          ) : null}

          <HomeIndicator />
        </div>
      </div>
    </AbsoluteFill>
  );
};

function ScaledScene({
  Component,
  compW,
  compH,
  defaultProps,
  overrideProps,
  insetTop,
  insetBottom,
  fitMode,
}: {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  Component: React.ComponentType<any>;
  compW: number;
  compH: number;
  defaultProps: Record<string, unknown>;
  overrideProps?: Record<string, unknown>;
  insetTop: number;
  insetBottom: number;
  fitMode: PhoneFitMode;
}) {
  // Pick the scale based on the composition's declared phoneFitMode:
  //   - "cover":  fill the full screen, may crop (chat in portrait mode).
  //   - "width":  fit to phone width, vertical letterbox (landscape stuff).
  //   - "contain": fit whole composition, both-axis letterbox.
  let fit: number;
  if (fitMode === "cover") {
    fit = Math.max(SCREEN_W / compW, SCREEN_H / compH);
  } else if (fitMode === "contain") {
    fit = Math.min(SCREEN_W / compW, SCREEN_H / compH);
  } else {
    fit = SCREEN_W / compW;
  }

  const renderedW = compW * fit;
  const renderedH = compH * fit;
  const offsetX = (SCREEN_W - renderedW) / 2;
  const offsetY = (SCREEN_H - renderedH) / 2;

  const merged = overrideProps
    ? { ...defaultProps, ...overrideProps }
    : defaultProps;

  // Safe-area padding only matters for "cover" mode — the inner composition
  // fills the entire screen and would otherwise sit under the dynamic island
  // and home indicator. For "width" / "contain" the composition is centered
  // with letterbox, so it doesn't reach those chrome zones to begin with.
  const safeArea =
    fitMode === "cover"
      ? { top: insetTop / fit, bottom: insetBottom / fit }
      : { top: 0, bottom: 0 };

  return (
    <div
      style={{
        position: "absolute",
        left: offsetX,
        top: offsetY,
        width: compW,
        height: compH,
        transform: `scale(${fit})`,
        transformOrigin: "top left",
      }}
    >
      <SafeAreaContext.Provider value={safeArea}>
        <Component {...merged} />
      </SafeAreaContext.Provider>
    </div>
  );
}

function FallbackScreen() {
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color: "rgba(255,255,255,0.4)",
        fontFamily:
          "-apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif",
        fontSize: 28,
      }}
    >
      Pick a composition
    </div>
  );
}

function DynamicIsland() {
  return (
    <div
      style={{
        position: "absolute",
        top: 22,
        left: "50%",
        transform: "translateX(-50%)",
        width: 240,
        height: 56,
        background: "#000",
        borderRadius: 999,
        boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.04)",
        display: "flex",
        alignItems: "center",
        justifyContent: "flex-end",
        paddingRight: 22,
        gap: 6,
      }}
    >
      <span
        style={{
          width: 8,
          height: 8,
          borderRadius: "50%",
          background: "#1c1c1f",
          boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)",
        }}
      />
    </div>
  );
}

function Notch() {
  return (
    <div
      style={{
        position: "absolute",
        top: 0,
        left: "50%",
        transform: "translateX(-50%)",
        width: 360,
        height: 38,
        background: "#000",
        borderBottomLeftRadius: 22,
        borderBottomRightRadius: 22,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        gap: 14,
      }}
    >
      <span
        style={{
          width: 60,
          height: 8,
          background: "#101012",
          borderRadius: 999,
        }}
      />
      <span
        style={{
          width: 10,
          height: 10,
          borderRadius: "50%",
          background: "#101012",
        }}
      />
    </div>
  );
}

function HomeIndicator() {
  return (
    <div
      style={{
        position: "absolute",
        bottom: 14,
        left: "50%",
        transform: "translateX(-50%)",
        width: 270,
        height: 6,
        background: "rgba(255,255,255,0.55)",
        borderRadius: 999,
        mixBlendMode: "exclusion",
      }}
    />
  );
}

function SideButton({
  side,
  top,
  length,
}: {
  side: "left" | "right";
  top: number;
  length: number;
}) {
  return (
    <div
      style={{
        position: "absolute",
        top,
        [side]: -3,
        width: 5,
        height: length,
        background:
          "linear-gradient(90deg, #1a1a1c 0%, #3a3a3e 50%, #1a1a1c 100%)",
        borderRadius: 2,
      }}
    />
  );
}
Save as PhoneFrame/PhoneFrame.tsx