Keyloom

iMessage Thread

A scripted iMessage conversation on a real-proportioned iPhone: typing dots, composer typing, tapback reactions, photo stacks, tappable link cards, read receipts and a status-bar clock that follows the story.

Preview

A scripted iMessage conversation on a real-proportioned iPhone: typing dots, composer typing, tapback reactions, photo stacks, tappable link cards, read receipts and a status-bar clock that follows the story.

Open editor

Props

NameTypeDefault
contactNamestring"GAIA"
layout"phone" | "fullscreen""fullscreen"
keyboardbooleanfalse
soundsbooleantrue

Composition

ID
IMessageThread
Resolution
1080×1920
FPS
60
Duration
30.9s

Source

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

"use client";
import { useMemo } from "react";
import { AbsoluteFill, interpolate, Sequence, useVideoConfig } from "remotion";
import { type ClipStyle, resolveClipStyle } from "../../clip-style";
import { SmartAudio } from "../../smart-audio";
import { SmartVideo } from "../../smart-video";
import { DESIGN_FPS, useDesignFrame } from "../../use-design-frame";
import { keyboardSfx, threadAsset } from "../_imessage-thread/asset";
import {
  type Beat,
  type BuiltThread,
  buildThread,
  SEND_PAUSE,
} from "../_imessage-thread/build";
import { SF_DISPLAY, SF_TEXT } from "../_imessage-thread/fonts";
import { type Look, lookFromTheme } from "../_imessage-thread/look";
import { Outro } from "../_imessage-thread/Outro";
import { Phone, SCREEN_W } from "../_imessage-thread/Phone";
import { StatusBar } from "../_imessage-thread/StatusBar";
import {
  type IMessageThreadHook,
  type IMessageThreadMusic,
  type IMessageThreadOutro,
  videoHookSeconds,
} from "../_imessage-thread/segments";
import { Thread } from "../_imessage-thread/Thread";
import type { ThreadItem } from "../_imessage-thread/types";
import { TEXT_VARIANTS } from "../Text/Text";
import {
  TextSoftBlurIn,
  textSoftBlurInFrames,
} from "../TextSoftBlurIn/TextSoftBlurIn";

export type IMessageThreadProps = {
  beats: Beat[];
  contactName: string;
  /** Path under public/imessage-thread/. */
  contactAvatar: string;
  /** "phone": iPhone mockup on a soft backdrop. "fullscreen": the screen itself, like a screen recording. */
  layout: "phone" | "fullscreen";
  hook?: IMessageThreadHook;
  /** Blur-in title card before the chat ("daily briefings are here"). */
  title?: {
    headline: string;
    subtitle?: string;
    size?: number;
    /** Any Text picker animation value. Defaults to soft-blur. */
    animation?: string;
  };
  /** Logo call to action after the chat. */
  outro?: IMessageThreadOutro;
  /** Background music under the whole video; loops if shorter. */
  music?: IMessageThreadMusic;
  /** Show the iOS 18 keyboard under the composer while chatting (iOS 18 theme only). */
  keyboard?: boolean;
  /** Play the iMessage send, receive and tapback sounds. */
  sounds?: boolean;
  clipStyle?: ClipStyle;
  /** Curated theme id from meta.themes: "ios26" (default) or "ios18". */
  clipTheme?: string;
};

const PHONE_W = 390;
const PHONE_H = 815;
const PHONE_BOTTOM_MARGIN = 0.02;
const HOOK_FADE_S = 0.25;
const SFX_SECONDS = 0.6;
const KEY_SFX_SECONDS = 0.12;
const MIN_KEY_GAP_S = 0.07;

// When each character appears in the composer (Thread reveals round(len * k) chars), thinned to MIN_KEY_GAP_S.
function keyTapTimes(text: string, start: number, compose: number) {
  const n = Array.from(text).length;
  const times: number[] = [];
  let last = Number.NEGATIVE_INFINITY;
  for (let i = 1; i <= n; i++) {
    const at = start + (compose * (i - 0.5)) / n;
    if (at - last >= MIN_KEY_GAP_S) {
      times.push(at);
      last = at;
    }
  }
  return times;
}

export const IMessageThread: React.FC<IMessageThreadProps> = ({
  beats,
  contactName,
  contactAvatar,
  layout,
  hook,
  title,
  outro,
  music,
  keyboard = false,
  sounds = true,
  clipStyle,
  clipTheme,
}) => {
  const { fps } = useVideoConfig();
  const built = useMemo(() => buildThread(beats), [beats]);
  const chat = (overlay?: IMessageThreadHook) => (
    <Chat
      built={built}
      contactName={contactName}
      contactAvatar={contactAvatar}
      layout={layout}
      overlay={overlay}
      keyboard={keyboard}
      sounds={sounds}
      clipStyle={clipStyle}
      look={lookFromTheme(clipTheme)}
    />
  );

  const hookFrames = Math.round(videoHookSeconds(hook) * fps);
  const chatFrames = Math.ceil(built.end * fps);
  const titleFrames = title
    ? Math.round((textSoftBlurInFrames(title.headline) * fps) / DESIGN_FPS)
    : 0;
  const TitleCard = title
    ? (TEXT_VARIANTS[title.animation ?? ""] ?? TextSoftBlurIn)
    : null;
  return (
    <AbsoluteFill style={{ background: "#000" }}>
      {title && TitleCard && (
        <Sequence durationInFrames={titleFrames} name="title">
          <TitleCard
            headline={title.headline}
            subtitle={title.subtitle ?? ""}
            clipStyle={{ backgroundColor: "#08090d", textColor: "#ffffff" }}
            headlineSizeVmin={title.size}
          />
        </Sequence>
      )}
      {hook?.video && (
        <Sequence
          from={titleFrames}
          durationInFrames={hookFrames}
          name="video-hook"
        >
          <AbsoluteFill>
            <SmartVideo
              src={threadAsset(hook.video)}
              muted
              objectFit="cover"
              style={{ width: "100%", height: "100%" }}
            />
            <HookCaption text={hook.text} top={0.3} />
          </AbsoluteFill>
        </Sequence>
      )}
      <Sequence
        from={titleFrames + hookFrames}
        durationInFrames={outro ? chatFrames : undefined}
        name="chat"
      >
        {chat(hook?.video ? undefined : hook)}
      </Sequence>
      {outro && (
        <Sequence from={titleFrames + hookFrames + chatFrames} name="outro">
          <Outro url={outro.url} />
        </Sequence>
      )}
      {music && <MusicTrack music={music} />}
    </AbsoluteFill>
  );
};

const MUSIC_VOLUME = 0.3;
const MUSIC_FADE_IN_S = 0.6;
const MUSIC_FADE_OUT_S = 1.2;

function MusicTrack({ music }: { music: IMessageThreadMusic }) {
  const { fps, durationInFrames } = useVideoConfig();
  const peak = music.volume ?? MUSIC_VOLUME;
  return (
    <Sequence name="music" layout="none">
      <SmartAudio
        src={threadAsset(music.src)}
        loop
        volume={(f) =>
          peak *
          interpolate(
            f,
            [
              0,
              MUSIC_FADE_IN_S * fps,
              durationInFrames - MUSIC_FADE_OUT_S * fps,
              durationInFrames,
            ],
            [0, 1, 1, 0],
            { extrapolateLeft: "clamp", extrapolateRight: "clamp" },
          )
        }
      />
    </Sequence>
  );
}

function Chat({
  built,
  contactName,
  contactAvatar,
  layout,
  overlay,
  keyboard,
  sounds,
  clipStyle,
  look,
}: {
  built: BuiltThread;
  contactName: string;
  contactAvatar: string;
  layout: IMessageThreadProps["layout"];
  overlay?: IMessageThreadHook;
  keyboard: boolean;
  sounds: boolean;
  clipStyle?: ClipStyle;
  look: Look;
}) {
  const t = useDesignFrame() / DESIGN_FPS;
  const { width, height } = useVideoConfig();
  const s = resolveClipStyle(clipStyle, {
    background:
      "radial-gradient(120% 90% at 50% 40%, #FFFFFF 0%, #F2F2F5 70%, #ECECF0 100%)",
    color: "#000000",
    fontFamily: SF_TEXT,
    accent: "#0A7CFF",
  });
  const clock =
    [...built.clock].reverse().find((c) => t >= c.at)?.time ??
    built.clock[0]?.time ??
    "9:41";
  const thread = (
    <Thread
      script={built.items}
      contactName={contactName}
      contactAvatar={contactAvatar}
      keyboard={keyboard}
      look={look}
    />
  );
  const overlayOpacity = overlay
    ? interpolate(t, [overlay.seconds - HOOK_FADE_S, overlay.seconds], [1, 0], {
        extrapolateLeft: "clamp",
        extrapolateRight: "clamp",
      })
    : 0;
  const sfx = sounds && <MessageSounds items={built.items} />;

  if (layout === "fullscreen") {
    const scale = width / SCREEN_W;
    return (
      <AbsoluteFill style={{ background: "#fff" }}>
        <div
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: SCREEN_W,
            height: height / scale,
            transform: `scale(${scale})`,
            transformOrigin: "top left",
            overflow: "hidden",
            background: "#fff",
            fontFamily: SF_TEXT,
          }}
        >
          {thread}
          <StatusBar time={clock} />
        </div>
        {overlay && (
          <HookCaption text={overlay.text} opacity={overlayOpacity} />
        )}
        {sfx}
      </AbsoluteFill>
    );
  }

  const phoneScale = Math.min(
    (width * 0.9) / PHONE_W,
    (height * (overlay ? 0.8 : 0.94)) / PHONE_H,
  );
  const phoneTop = height * (1 - PHONE_BOTTOM_MARGIN) - PHONE_H * phoneScale;
  return (
    <AbsoluteFill style={{ background: s.background }}>
      <div
        style={{
          position: "absolute",
          top: overlay ? phoneTop : (height - PHONE_H * phoneScale) / 2,
          left: (width - PHONE_W * phoneScale) / 2,
          transform: `scale(${phoneScale})`,
          transformOrigin: "top left",
        }}
      >
        <Phone time={clock}>{thread}</Phone>
      </div>
      {overlay && (
        <HookCaption
          text={overlay.text}
          top={phoneTop / 2 / height}
          opacity={overlayOpacity}
        />
      )}
      {sfx}
    </AbsoluteFill>
  );
}

// Sent swoosh for the user's messages, received note for GAIA's, tap sound for reactions.
function MessageSounds({ items }: { items: ThreadItem[] }) {
  const { fps } = useVideoConfig();
  const duration = Math.ceil(SFX_SECONDS * fps);
  const keyDuration = Math.max(1, Math.ceil(KEY_SFX_SECONDS * fps));
  return (
    <>
      {items.flatMap((item) => {
        if (item.kind === "divider") return [];
        const sound =
          item.kind === "reaction"
            ? "sounds/tapback.mp3"
            : item.from === "user"
              ? "sounds/sent.mp3"
              : "sounds/received.mp3";
        const typing =
          item.kind === "text" && item.compose
            ? keyTapTimes(
                item.text,
                item.at - item.compose - SEND_PAUSE,
                item.compose,
              ).map((at, i) => (
                <Sequence
                  key={`key-${item.id}-${i}`}
                  from={Math.round(at * fps)}
                  durationInFrames={keyDuration}
                  name="key-sfx"
                  layout="none"
                >
                  <SmartAudio src={keyboardSfx()} volume={0.4} />
                </Sequence>
              ))
            : [];
        return [
          ...typing,
          <Sequence
            key={`sfx-${item.id}`}
            from={Math.round(item.at * fps)}
            durationInFrames={duration}
            name="message-sfx"
            layout="none"
          >
            <SmartAudio src={threadAsset(sound)} volume={0.8} />
          </Sequence>,
        ];
      })}
    </>
  );
}

// TikTok-classic caption: bold white text with a black outline, centred at `top` (fraction of height).
function HookCaption({
  text,
  top = 0.3,
  opacity = 1,
}: {
  text: string;
  top?: number;
  opacity?: number;
}) {
  const { width, height } = useVideoConfig();
  if (opacity <= 0) return null;
  const fontSize = width * 0.058;
  return (
    <div
      style={{
        position: "absolute",
        left: width * 0.08,
        right: width * 0.08,
        top: height * top,
        transform: "translateY(-50%)",
        textAlign: "center",
        fontFamily: SF_DISPLAY,
        fontWeight: 700,
        fontSize,
        lineHeight: 1.18,
        letterSpacing: "0.005em",
        wordSpacing: "0.1em",
        opacity,
        zIndex: 50,
        whiteSpace: "pre-line",
      }}
    >
      {/* Outline on its own layer so the stroke never eats into the white fill. */}
      <span
        style={{
          color: "#000",
          WebkitTextStroke: `${fontSize * 0.14}px #000`,
        }}
      >
        {text}
      </span>
      <span style={{ position: "absolute", inset: 0, color: "#fff" }}>
        {text}
      </span>
    </div>
  );
}
Save as IMessageThread/IMessageThread.tsx