Instagram Messages

An animated Instagram DM conversation with the gradient sent bubble, typing dots, and stacking spring physics.

Preview

Open editor

Usage

Instagram DM variant of the messages template. side: "right" is the gradient sent bubble (Instagram's signature purple → pink → orange), side: "left" is the grey received bubble. The header shows the contact name with an Active now indicator and the gradient story ring around the avatar.

Same ChatMessage shape as the iMessage and WhatsApp components — text, side, typingFrames, delay. Bubbles spring in, the typing dots show until typingFrames elapses, then the bubble pops in. Older messages slide upward when newer ones arrive.

Props

NameTypeDefault
contactNamestring"sanku"
contactAvatarstring"https://avatars.githubusercontent.com/aryanranderiya?s=200"
messagesChatMessage[][5 items]
theme"light" | "dark""light"
orientation"landscape" | "portrait""landscape"

Composition

ID
InstagramMessages
Resolution
1280×720
FPS
60
Duration
11.0s

Source

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

"use client";
import { Audio } from "@remotion/media";
import {
  AbsoluteFill,
  Img,
  Sequence,
  spring,
  staticFile,
  useVideoConfig,
} from "remotion";
import type { ChatMessage } from "../../editors/types";
import { proxyExternalImg } from "../../proxy-image";
import { useSafeArea } from "../../safe-area";
import { snap } from "../../snap";
import { useDesignFrame } from "../../use-design-frame";

export type InstagramMessagesProps = {
  contactName: string;
  contactAvatar?: string;
  messages: ChatMessage[];
  theme: "light" | "dark";
  orientation?: "landscape" | "portrait";
};

// 9:19.5 — modern iPhone screen aspect ratio.
const PORTRAIT_ASPECT = "9 / 19.5";

const ROW_GAP = 18;
const BOTTOM_PADDING = 36;
const COMPOSER_HEIGHT = 96;
const HEADER_HEIGHT = 110;
const SIDE_PADDING = 48;

const SENT_BG = "#A23CF8";

type Palette = {
  bg: string;
  headerBg: string;
  headerBorder: string;
  headerText: string;
  headerSub: string;
  headerIcon: string;
  receivedBg: string;
  bubbleSentText: string;
  bubbleReceivedText: string;
  composerBg: string;
  composerBorder: string;
  composerInputBg: string;
  composerIcon: string;
  composerPlaceholder: string;
  dot: string;
};

function getPalette(theme: "light" | "dark"): Palette {
  if (theme === "dark") {
    return {
      bg: "#000000",
      headerBg: "#000000",
      headerBorder: "rgba(255,255,255,0.1)",
      headerText: "#ffffff",
      headerSub: "rgba(255,255,255,0.6)",
      headerIcon: "#ffffff",
      receivedBg: "#262626",
      bubbleSentText: "#ffffff",
      bubbleReceivedText: "#f5f5f5",
      composerBg: "#000000",
      composerBorder: "rgba(255,255,255,0.1)",
      composerInputBg: "#1c1c1c",
      composerIcon: "#ffffff",
      composerPlaceholder: "rgba(255,255,255,0.5)",
      dot: "rgba(255,255,255,0.55)",
    };
  }
  return {
    bg: "#ffffff",
    headerBg: "#ffffff",
    headerBorder: "rgba(0,0,0,0.08)",
    headerText: "#0f1014",
    headerSub: "rgba(15,16,20,0.55)",
    headerIcon: "#0f1014",
    receivedBg: "#efefef",
    bubbleSentText: "#ffffff",
    bubbleReceivedText: "#0f1014",
    composerBg: "#ffffff",
    composerBorder: "rgba(0,0,0,0.08)",
    composerInputBg: "#ffffff",
    composerIcon: "#0f1014",
    composerPlaceholder: "rgba(15,16,20,0.45)",
    dot: "rgba(15,16,20,0.5)",
  };
}

export const InstagramMessages: React.FC<InstagramMessagesProps> = ({
  contactName,
  contactAvatar = "https://avatars.githubusercontent.com/aryanranderiya?s=200",
  messages,
  theme,
  orientation = "landscape",
}) => {
  const frame = useDesignFrame();
  const { fps } = useVideoConfig();
  const palette = getPalette(theme);
  const safe = useSafeArea();
  // Auto-portrait inside a device frame (see ChatFill for the same logic).
  const inDeviceFrame = safe.top > 0 || safe.bottom > 0;
  const effectiveOrientation =
    inDeviceFrame && orientation === "landscape" ? "portrait" : orientation;

  const chat = (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: palette.bg,
        color: palette.bubbleReceivedText,
        fontFamily:
          "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
        overflow: "hidden",
        display: "flex",
        flexDirection: "column",
      }}
    >
      <Header
        name={contactName}
        avatar={contactAvatar}
        frame={frame}
        fps={fps}
        palette={palette}
      />
      <Conversation
        frame={frame}
        fps={fps}
        messages={messages}
        palette={palette}
      />
      <Composer palette={palette} />
    </div>
  );

  const audio = messages.map((msg, i) => (
    <Sequence key={i} from={msg.delay + msg.typingFrames}>
      <Audio
        src={staticFile("sounds/message_bubble/message.mp3")}
        volume={0.85}
      />
    </Sequence>
  ));

  if (effectiveOrientation === "portrait") {
    // Parent-relative sizing — don't call useVideoConfig for layout here,
    // it returns the parent wrapper's canvas dims when nested in PhoneFrame.
    return (
      <AbsoluteFill
        style={{
          background: palette.bg,
          overflow: "hidden",
        }}
      >
        <div
          style={{
            position: "absolute",
            top: 0,
            bottom: 0,
            left: "50%",
            aspectRatio: PORTRAIT_ASPECT,
            transform: "translateX(-50%)",
            background: palette.bg,
            display: "flex",
            flexDirection: "column",
          }}
        >
          {safe.top > 0 && <div style={{ flexShrink: 0, height: safe.top }} />}
          <div style={{ flex: 1, minHeight: 0, position: "relative" }}>
            {chat}
          </div>
          {safe.bottom > 0 && (
            <div style={{ flexShrink: 0, height: safe.bottom }} />
          )}
        </div>
        {audio}
      </AbsoluteFill>
    );
  }

  return (
    <AbsoluteFill style={{ background: palette.bg, overflow: "hidden" }}>
      {chat}
      {audio}
    </AbsoluteFill>
  );
};

function Header({
  name,
  avatar,
  frame,
  fps,
  palette,
}: {
  name: string;
  avatar: string;
  frame: number;
  fps: number;
  palette: Palette;
}) {
  const enter = spring({
    frame,
    fps,
    config: { damping: 22, stiffness: 90 },
  });
  return (
    <div
      style={{
        height: HEADER_HEIGHT,
        background: palette.headerBg,
        borderBottom: `1px solid ${palette.headerBorder}`,
        padding: "0 20px",
        display: "flex",
        alignItems: "center",
        gap: 14,
        opacity: enter,
        transform: `translate3d(0, ${snap((1 - enter) * -8)}px, 0)`,
        flexShrink: 0,
      }}
    >
      <BackArrow color={palette.headerIcon} />
      <div
        style={{
          width: 70,
          height: 70,
          borderRadius: "50%",
          padding: 3,
          background:
            "conic-gradient(from 30deg, #feda75, #fa7e1e, #d62976, #962fbf, #4f5bd5, #feda75)",
          flexShrink: 0,
        }}
      >
        <div
          style={{
            width: "100%",
            height: "100%",
            borderRadius: "50%",
            background: palette.headerBg,
            padding: 2,
            boxSizing: "border-box",
            overflow: "hidden",
          }}
        >
          <Img
            src={proxyExternalImg(avatar)}
            crossOrigin="anonymous"
            alt={name}
            style={{
              width: "100%",
              height: "100%",
              objectFit: "cover",
              borderRadius: "50%",
            }}
          />
        </div>
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div
          style={{
            color: palette.headerText,
            fontSize: 28,
            fontWeight: 700,
            letterSpacing: "-0.005em",
            lineHeight: 1.2,
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap",
          }}
        >
          {name}
        </div>
        <div
          style={{
            color: palette.headerSub,
            fontSize: 18,
            marginTop: 2,
            fontWeight: 400,
          }}
        >
          Active now
        </div>
      </div>

      <HeaderIconButton color={palette.headerIcon}>
        <svg width="26" height="26" viewBox="0 0 24 24" fill="none">
          <path
            d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.86 19.86 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.72 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.35 1.85.59 2.81.72A2 2 0 0 1 22 16.92z"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </HeaderIconButton>
      <HeaderIconButton color={palette.headerIcon}>
        <svg width="28" height="28" viewBox="0 0 24 24" fill="none">
          <path
            d="m23 7-7 5 7 5V7zM14 5H3a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      </HeaderIconButton>
    </div>
  );
}

function BackArrow({ color }: { color: string }) {
  return (
    <div
      style={{
        width: 36,
        height: 36,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color,
        flexShrink: 0,
      }}
    >
      <svg width="26" height="26" viewBox="0 0 24 24" fill="none">
        <path
          d="M19 12H5M12 19l-7-7 7-7"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </div>
  );
}

function HeaderIconButton({
  color,
  children,
}: {
  color: string;
  children: React.ReactNode;
}) {
  return (
    <div
      style={{
        width: 40,
        height: 40,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color,
        flexShrink: 0,
      }}
    >
      {children}
    </div>
  );
}

function Composer({ palette }: { palette: Palette }) {
  return (
    <div
      style={{
        height: COMPOSER_HEIGHT,
        background: palette.composerBg,
        borderTop: `1px solid ${palette.composerBorder}`,
        padding: "0 16px",
        display: "flex",
        alignItems: "center",
        gap: 10,
        flexShrink: 0,
      }}
    >
      <div
        style={{
          width: 44,
          height: 44,
          borderRadius: "50%",
          background:
            "linear-gradient(135deg, #515bd4 0%, #dd2a7b 50%, #f58529 100%)",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexShrink: 0,
        }}
      >
        <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
          <path
            d="M12 5v14M5 12h14"
            stroke="#ffffff"
            strokeWidth="2.4"
            strokeLinecap="round"
          />
        </svg>
      </div>
      <div
        style={{
          flex: 1,
          height: 52,
          borderRadius: 26,
          background: palette.composerInputBg,
          border: `1px solid ${palette.composerBorder}`,
          display: "flex",
          alignItems: "center",
          padding: "0 18px",
          gap: 12,
        }}
      >
        <span
          style={{
            color: palette.composerPlaceholder,
            fontSize: 17,
            flex: 1,
          }}
        >
          Message...
        </span>
        <ComposerIcon color={palette.composerIcon} inline>
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <rect
              x="3"
              y="3"
              width="18"
              height="18"
              rx="3"
              stroke="currentColor"
              strokeWidth="2"
            />
            <circle cx="9" cy="9" r="1.6" fill="currentColor" />
            <path
              d="m21 15-4-4-9 9"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </ComposerIcon>
        <ComposerIcon color={palette.composerIcon} inline>
          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
            <path
              d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"
              stroke="currentColor"
              strokeWidth="2"
            />
            <path
              d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v4M8 22h8"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
            />
          </svg>
        </ComposerIcon>
      </div>
      <ComposerIcon color={palette.composerIcon}>
        <svg width="26" height="26" viewBox="0 0 24 24" fill="none">
          <path
            d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinejoin="round"
          />
        </svg>
      </ComposerIcon>
    </div>
  );
}

function ComposerIcon({
  color,
  inline,
  children,
}: {
  color: string;
  inline?: boolean;
  children: React.ReactNode;
}) {
  return (
    <div
      style={{
        width: inline ? "auto" : 40,
        height: inline ? "auto" : 40,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        color,
        flexShrink: 0,
      }}
    >
      {children}
    </div>
  );
}

function Conversation({
  frame,
  fps,
  messages,
  palette,
}: {
  frame: number;
  fps: number;
  messages: ChatMessage[];
  palette: Palette;
}) {
  const heights = messages.map((msg) => {
    const local = frame - msg.delay;
    if (local < 0) return 0;
    const isTyping = local < msg.typingFrames;
    return isTyping ? TYPING_BUBBLE_HEIGHT : estimateBubbleHeight(msg.text);
  });

  return (
    <div
      style={{
        flex: 1,
        position: "relative",
        overflow: "hidden",
      }}
    >
      {messages.map((msg, i) => (
        <MessageRow
          key={i}
          msg={msg}
          index={i}
          messages={messages}
          heights={heights}
          frame={frame}
          fps={fps}
          palette={palette}
        />
      ))}
    </div>
  );
}

function MessageRow({
  msg,
  index,
  messages,
  heights,
  frame,
  fps,
  palette,
}: {
  msg: ChatMessage;
  index: number;
  messages: ChatMessage[];
  heights: number[];
  frame: number;
  fps: number;
  palette: Palette;
}) {
  const local = frame - msg.delay;
  if (local < 0) return null;

  const isTyping = local < msg.typingFrames;

  let stackOffset = 0;
  for (let j = index + 1; j < messages.length; j++) {
    const newerLocal = frame - messages[j]!.delay;
    if (newerLocal < 0) continue;
    const progress = spring({
      frame: newerLocal,
      fps,
      config: { damping: 24, stiffness: 130, mass: 0.7 },
    });
    stackOffset += progress * (heights[j]! + ROW_GAP);
  }

  const bottom = BOTTOM_PADDING + stackOffset;

  return (
    <div
      style={{
        position: "absolute",
        bottom,
        left: SIDE_PADDING,
        right: SIDE_PADDING,
        display: "flex",
        justifyContent: msg.side === "right" ? "flex-end" : "flex-start",
      }}
    >
      {isTyping ? (
        <TypingBubble
          side={msg.side}
          localFrame={local}
          fps={fps}
          palette={palette}
        />
      ) : (
        <MessageBubble
          side={msg.side}
          text={msg.text}
          localFrame={local - msg.typingFrames}
          fps={fps}
          palette={palette}
        />
      )}
    </div>
  );
}

const TYPING_BUBBLE_HEIGHT = 78;

function estimateBubbleHeight(text: string): number {
  const charsPerLine = 28;
  const explicitNewlines = text.match(/\n/g)?.length ?? 0;
  const lines = Math.max(
    1,
    Math.ceil(text.length / charsPerLine) + explicitNewlines,
  );
  return 76 + (lines - 1) * 42;
}

function TypingBubble({
  side,
  localFrame,
  fps,
  palette,
}: {
  side: ChatMessage["side"];
  localFrame: number;
  fps: number;
  palette: Palette;
}) {
  const enter = spring({
    frame: localFrame,
    fps,
    config: { damping: 14, stiffness: 170, mass: 0.6 },
  });
  const isRight = side === "right";

  return (
    <div
      style={{
        background: isRight ? SENT_BG : palette.receivedBg,
        padding: "22px 28px",
        borderRadius: 36,
        display: "flex",
        gap: 10,
        alignItems: "center",
        opacity: enter,
        transform: `scale(${0.85 + enter * 0.15})`,
        transformOrigin: isRight ? "bottom right" : "bottom left",
      }}
    >
      {[0, 1, 2].map((i) => {
        const phase = (localFrame + i * 5) / 7;
        const yBob = Math.sin(phase) * 4;
        const dotOpacity = 0.45 + Math.sin(phase) * 0.3;
        return (
          <span
            key={i}
            style={{
              width: 12,
              height: 12,
              borderRadius: "50%",
              background: isRight ? "rgba(255,255,255,0.85)" : palette.dot,
              transform: `translate3d(0, ${snap(-Math.abs(yBob))}px, 0)`,
              opacity: dotOpacity,
            }}
          />
        );
      })}
    </div>
  );
}

function MessageBubble({
  side,
  text,
  localFrame,
  fps,
  palette,
}: {
  side: ChatMessage["side"];
  text: string;
  localFrame: number;
  fps: number;
  palette: Palette;
}) {
  const pop = spring({
    frame: localFrame,
    fps,
    config: { damping: 14, stiffness: 180, mass: 0.55 },
  });
  const isRight = side === "right";

  return (
    <div
      style={{
        background: isRight ? SENT_BG : palette.receivedBg,
        color: isRight ? palette.bubbleSentText : palette.bubbleReceivedText,
        padding: "18px 26px",
        borderRadius: 36,
        maxWidth: 880,
        minWidth: 80,
        fontSize: 30,
        fontWeight: 500,
        lineHeight: 1.32,
        letterSpacing: "-0.005em",
        opacity: pop,
        transform: `scale(${0.85 + pop * 0.15})`,
        transformOrigin: isRight ? "bottom right" : "bottom left",
        wordWrap: "break-word",
        whiteSpace: "pre-wrap",
      }}
    >
      {text}
    </div>
  );
}
Save as InstagramMessages/InstagramMessages.tsx