R RenderComp
remotion chat-story faceless-content animation tutorial

How to Create Chat Story and Text Message Videos with Remotion

How to Create Chat Story and Text Message Videos with Remotion

Chat story videos — fake text message conversations that play out on screen, bubble by bubble — are one of the most reliably watched formats in short-form video. No face, no voice, no camera. Just a conversation that unfolds with typing indicators, message pops, and notification sounds, and an audience that stays until the last message to find out how it ends.

Most creators build these in mobile “fake texting” apps or manually keyframe bubbles in a video editor. Both approaches fall apart the moment you want volume: episode 40 takes just as long to produce as episode 1.

Remotion changes the economics completely. A chat story is, at its core, structured data — a list of messages with senders and timing. That makes it the perfect Remotion use case: write the conversation as JSON, feed it to a React component, and render a finished vertical video. Episode 40 takes exactly as long to produce as episode 1 — the time it takes to write the script.

This guide builds a complete chat story pipeline: the JSON script format, the message bubble component, typing indicators, sound design, automatic video duration, and batch rendering for entire seasons of episodes.


Why Chat Story Videos Dominate Faceless Short-Form Channels

The format works for structural reasons, not trends:

  • Zero on-camera requirements. No presenter, no voice recording, no filming. The entire production is writing plus rendering.
  • Built-in retention mechanics. Every message is a micro-cliffhanger. The typing indicator — three dots bouncing while the viewer waits — is one of the strongest “keep watching” signals that exists, because everyone has felt that exact anticipation in real life.
  • Episodic by nature. A good story splits into parts, and “Part 2 in the next video” drives follow-through better than almost any other device.
  • Infinitely reskinnable. The same story can render as an iMessage-style thread, a LINE-style thread, or a fictional app skin — one script, multiple looks.

The catch is production volume. Channels in this niche often post daily. That is exactly the situation where a programmatic pipeline beats manual editing, and why the rest of this article treats the video as a render target for data rather than a timeline to edit.


Modeling a Conversation as a JSON Script

Start by separating content from presentation. The conversation lives in a JSON file; the React components decide how it looks and moves. Writers (or an LLM writing drafts for you) only ever touch the JSON.

// src/types.ts
export type ChatMessage = {
  from: 'me' | 'them';
  text: string;
  typingMs?: number; // how long the typing indicator shows before this message
  readMs?: number;   // pause after this message appears, before the next one starts
};

export type ChatScript = {
  contact: string; // name shown in the chat header
  messages: ChatMessage[];
};

export type ChatStoryProps = {
  script: ChatScript;
};

An episode is then just a file:

{
  "contact": "Unknown Number",
  "messages": [
    { "from": "them", "text": "hey, is this Sarah?", "typingMs": 900, "readMs": 1400 },
    { "from": "me", "text": "no, wrong number. who's this?", "typingMs": 1100, "readMs": 1200 },
    { "from": "them", "text": "weird. this is the number she gave me last night", "typingMs": 1600, "readMs": 1800 },
    { "from": "me", "text": "wait... describe her", "typingMs": 800, "readMs": 2200 }
  ]
}

Two things are worth noting. First, timing lives in the script, not the component — pacing is a writing decision, and a dramatic pause before a reveal (readMs: 2200) belongs next to the words it supports. Second, typingMs and readMs are optional; the components fall back to defaults, so a quickly drafted script still renders with sensible rhythm.


Building the Message Bubble Component (iMessage / LINE Styles)

The bubble itself is a styled div that springs into place when its <Sequence> starts. Because useCurrentFrame() resets to 0 inside a <Sequence>, the spring always begins fresh at the moment the message “arrives.”

// src/MessageBubble.tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';

type BubbleProps = {
  text: string;
  isMe: boolean;
};

export const MessageBubble: React.FC<BubbleProps> = ({ text, isMe }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const pop = spring({
    frame,
    fps,
    config: { mass: 0.6, stiffness: 180, damping: 14 },
  });

  const scale = interpolate(pop, [0, 1], [0.6, 1]);
  const translateY = interpolate(pop, [0, 1], [16, 0]);
  const opacity = interpolate(pop, [0, 1], [0, 1], { extrapolateRight: 'clamp' });

  return (
    <div
      style={{
        display: 'flex',
        justifyContent: isMe ? 'flex-end' : 'flex-start',
        padding: '6px 28px',
      }}
    >
      <div
        style={{
          maxWidth: '72%',
          padding: '16px 22px',
          borderRadius: 24,
          borderBottomRightRadius: isMe ? 6 : 24,
          borderBottomLeftRadius: isMe ? 24 : 6,
          fontSize: 32,
          lineHeight: 1.35,
          fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
          backgroundColor: isMe ? '#0b84ff' : '#e9e9eb',
          color: isMe ? '#ffffff' : '#111111',
          transform: `translateY(${translateY}px) scale(${scale})`,
          transformOrigin: isMe ? 'bottom right' : 'bottom left',
          opacity,
        }}
      >
        {text}
      </div>
    </div>
  );
};

The details that make this read as “real”:

  • transformOrigin at the bubble’s tail corner. Real messaging apps grow the bubble from where it attaches to the thread edge, not from its center. bottom right for sent, bottom left for received.
  • Asymmetric corner radius. The flattened corner on the tail side is the single strongest visual cue of a chat bubble.
  • System font stack. -apple-system resolves to the same family iMessage uses on macOS renderers, and the fallbacks keep Linux CI renders clean. No webfont request, no external dependency.

For a LINE-style look (the dominant messenger in Japan and a popular skin for this format), swap tokens instead of components:

const THEMES = {
  imessage: {
    background: '#ffffff',
    meBubble: '#0b84ff', meText: '#ffffff',
    themBubble: '#e9e9eb', themText: '#111111',
  },
  line: {
    background: '#7494c0',
    meBubble: '#8de055', meText: '#111111',
    themBubble: '#ffffff', themText: '#111111',
  },
} as const;

Pass the theme through props and every episode can render in both skins from the same script. Keep the styling “inspired by” rather than pixel-cloned — recreate the general grammar (bubble sides, colors, layout) without copying logos, icons, or exact UI assets from a real app.


Timing: Typing Indicators, Message Pops, and Spring Entrances

The conversation’s rhythm comes from converting the script’s millisecond values into frame offsets. One helper computes the whole timeline and becomes the single source of truth for both the composition and (later) the video’s duration:

// src/timing.ts
import type { ChatMessage } from './types';

export const getScriptTiming = (messages: ChatMessage[], fps: number) => {
  let cursor = 0;

  const timings = messages.map((message) => {
    const typingFrames = Math.round(((message.typingMs ?? 800) / 1000) * fps);
    const readFrames = Math.round(((message.readMs ?? 1400) / 1000) * fps);

    const typingStart = cursor;
    const popFrame = typingStart + typingFrames;
    cursor = popFrame + readFrames;

    return { typingStart, typingFrames, popFrame };
  });

  return { timings, totalFrames: cursor };
};

The typing indicator is three dots pulsing in a staggered wave. Everything is driven by the frame value — no CSS animations, which do not render deterministically in Remotion:

// src/TypingIndicator.tsx
import { useCurrentFrame, interpolate } from 'remotion';

export const TypingIndicator: React.FC = () => {
  const frame = useCurrentFrame();

  return (
    <div style={{ display: 'flex', justifyContent: 'flex-start', padding: '6px 28px' }}>
      <div
        style={{
          display: 'flex',
          gap: 8,
          padding: '20px 22px',
          borderRadius: 24,
          borderBottomLeftRadius: 6,
          backgroundColor: '#e9e9eb',
        }}
      >
        {[0, 1, 2].map((i) => {
          const cycle = (frame + i * 8) % 24;
          const lift = interpolate(cycle, [0, 6, 12, 24], [0, -6, 0, 0]);
          const opacity = interpolate(cycle, [0, 6, 12, 24], [0.4, 1, 0.4, 0.4]);
          return (
            <div
              key={i}
              style={{
                width: 12,
                height: 12,
                borderRadius: 6,
                backgroundColor: '#8e8e93',
                transform: `translateY(${lift}px)`,
                opacity,
              }}
            />
          );
        })}
      </div>
    </div>
  );
};

Now assemble the thread. Each message gets two sequences: the typing indicator (shown only for the other person, ending exactly when the bubble pops) and the bubble itself, which stays mounted for the rest of the video. A bottom-anchored flex column gives you the “thread pushes up as new messages arrive” behavior for free — no scroll math required:

// src/ChatStory.tsx
import React from 'react';
import { AbsoluteFill, Sequence, useVideoConfig } from 'remotion';
import { MessageBubble } from './MessageBubble';
import { TypingIndicator } from './TypingIndicator';
import { getScriptTiming } from './timing';
import type { ChatStoryProps } from './types';

export const ChatStory: React.FC<ChatStoryProps> = ({ script }) => {
  const { fps } = useVideoConfig();
  const { timings } = getScriptTiming(script.messages, fps);

  return (
    <AbsoluteFill style={{ backgroundColor: '#ffffff' }}>
      {/* Chat header */}
      <div
        style={{
          height: 150,
          display: 'flex',
          alignItems: 'flex-end',
          justifyContent: 'center',
          paddingBottom: 18,
          borderBottom: '1px solid #d1d1d6',
          fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
          fontSize: 34,
          fontWeight: 600,
          color: '#111111',
        }}
      >
        {script.contact}
      </div>

      {/* Message thread, anchored to the bottom */}
      <AbsoluteFill
        style={{
          top: 150,
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          paddingBottom: 80,
          overflow: 'hidden',
        }}
      >
        {script.messages.map((message, i) => {
          const t = timings[i];
          return (
            <React.Fragment key={i}>
              {message.from === 'them' && (
                <Sequence from={t.typingStart} durationInFrames={t.typingFrames} layout="none">
                  <TypingIndicator />
                </Sequence>
              )}
              <Sequence from={t.popFrame} layout="none">
                <MessageBubble text={message.text} isMe={message.from === 'me'} />
              </Sequence>
            </React.Fragment>
          );
        })}
      </AbsoluteFill>
    </AbsoluteFill>
  );
};

layout="none" matters here: without it, each <Sequence> renders as an absolute fill and the bubbles would stack on top of each other instead of flowing in the column. For “me” messages there is no visible indicator, but the typingMs delay still applies — it reads as the natural pause while “you” type your reply.

The spring config on the bubble (stiffness: 180, damping: 14) gives a quick pop with a hint of overshoot, which matches the energy of real messaging apps. If you want to tune the feel — snappier, bouncier, softer — the trade-offs between mass, stiffness, and damping are covered in depth in our Remotion spring animation guide.


Sound Design: Keyboard Clicks, Send Sounds, and Notifications

Chat stories are watched with sound on more often than most short-form content, because the audio is the story’s rhythm section: keyboard clatter during the dots, a swoosh on send, a tri-tone on receive. Remotion places these deterministically with the same <Sequence> offsets that drive the visuals.

Install the media package and put your sound files in public/sfx/:

npx remotion add @remotion/media
import { Audio } from '@remotion/media';
import { Sequence, staticFile } from 'remotion';

// Inside <ChatStory>, alongside the bubble sequences:
{script.messages.map((message, i) => {
  const t = timings[i];
  return (
    <React.Fragment key={`sfx-${i}`}>
      {/* Keyboard clicks while the other person is typing */}
      {message.from === 'them' && (
        <Sequence from={t.typingStart} durationInFrames={t.typingFrames}>
          <Audio src={staticFile('sfx/keyboard-loop.mp3')} loop volume={0.3} />
        </Sequence>
      )}
      {/* Send vs. receive sound at the exact pop frame */}
      <Sequence from={t.popFrame}>
        <Audio
          src={staticFile(message.from === 'me' ? 'sfx/send.mp3' : 'sfx/receive.mp3')}
          volume={0.7}
        />
      </Sequence>
    </React.Fragment>
  );
})}

Because the audio sequences reuse the same timings array as the bubbles, sound and visuals can never drift apart — change a typingMs in the JSON and both move together. Source your effects from licensed SFX libraries or record them yourself (a phone keyboard recorded close-mic sounds surprisingly right), and keep the keyboard loop quiet: it should register as texture, not percussion. Layering a low background music bed under everything is one more <Audio> at the composition root with volume={0.15} and loop.


Auto-Sizing Video Duration to the Script with calculateMetadata

Every episode has a different length, so hardcoding durationInFrames is a non-starter. calculateMetadata runs before rendering, receives the input props, and returns the correct duration — reusing the same getScriptTiming helper, so the composition and the metadata can never disagree:

// src/Root.tsx
import { Composition, CalculateMetadataFunction } from 'remotion';
import { ChatStory } from './ChatStory';
import { getScriptTiming } from './timing';
import type { ChatStoryProps } from './types';
import defaultScript from '../stories/wrong-number-part-1.json';

const FPS = 30;

const calculateMetadata: CalculateMetadataFunction<ChatStoryProps> = ({ props }) => {
  const { totalFrames } = getScriptTiming(props.script.messages, FPS);
  const outroFrames = 2 * FPS; // hold on the final message

  return {
    durationInFrames: totalFrames + outroFrames,
  };
};

export const RemotionRoot: React.FC = () => {
  return (
    <Composition
      id="ChatStory"
      component={ChatStory}
      fps={FPS}
      width={1080}
      height={1920}
      durationInFrames={300} // placeholder; overridden by calculateMetadata
      defaultProps={{ script: defaultScript }}
      calculateMetadata={calculateMetadata}
    />
  );
};

The two-second outro hold is not decorative — it gives viewers time to absorb the final message (usually the cliffhanger) and gives the platform’s UI a moment before the loop restarts. For more patterns like fetching remote data or adjusting dimensions in calculateMetadata, see the dynamic duration guide.


Batch-Generating Episodes from a Story Folder

Here is where the pipeline pays for itself. Drop every episode’s JSON into a stories/ folder and render them all with the Node.js APIs. selectComposition runs calculateMetadata with each episode’s props, so every video automatically gets its own correct duration:

// render-all.ts — run with: npx tsx render-all.ts
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
import fs from 'fs';
import path from 'path';

const storiesDir = path.join(process.cwd(), 'stories');
const episodes = fs.readdirSync(storiesDir).filter((f) => f.endsWith('.json'));

const serveUrl = await bundle({
  entryPoint: path.join(process.cwd(), 'src', 'index.ts'),
});

for (const file of episodes) {
  const script = JSON.parse(
    fs.readFileSync(path.join(storiesDir, file), 'utf-8'),
  );
  const inputProps = { script };

  const composition = await selectComposition({
    serveUrl,
    id: 'ChatStory',
    inputProps,
  });

  await renderMedia({
    composition,
    serveUrl,
    codec: 'h264',
    inputProps,
    outputLocation: `out/${path.basename(file, '.json')}.mp4`,
  });

  console.log(`Rendered ${file} (${composition.durationInFrames} frames)`);
}

Write ten episodes on Sunday, run one command, and the week’s uploads are sitting in out/. If volume grows beyond what a local machine renders comfortably, the same composition works unchanged with @remotion/lambda for parallel cloud rendering — the script format and components do not care where the frames are drawn.

This is also the natural place to bolt on generation: a script that prompts an LLM for a conversation in your JSON schema, validates it, and drops it into stories/ turns the pipeline into a full content system with a human review step in the middle.


Vertical-Ready Chat Templates to Skip the Setup

A few production notes before you ship:

  • Render at 1080×1920. Chat threads are portrait-native content; the format only makes sense vertical.
  • Respect platform safe zones. Short-form players overlay UI on the top and bottom edges. The header at the top of our composition sits partially in the risky zone — keep critical text below roughly the top 12% of the frame, and keep the last bubble above the bottom action bar area (the paddingBottom: 80 in the thread container is doing that job).
  • Design for sound-off as a fallback. The format survives muted viewing because the text is the content — one more structural advantage over voiceover-driven formats. If you later expand into multi-format output, the same script can drive square or landscape variants; see Instagram Reels and TikTok automation with Remotion for how teams wire this into posting workflows.

Everything in this article is buildable in an afternoon. What takes longer is the polish layer: read receipts, timestamp clusters, image messages, reaction animations, realistic status bars, multiple themed skins, and the dozens of small timing decisions that make a thread feel like a real phone rather than a diagram of one.


FAQ

Q: Can I make the text type out character by character instead of popping in?

Yes — derive a substring from the local frame: text.slice(0, Math.floor(frame * charsPerFrame)) inside the bubble. In practice, most chat story channels show the typing indicator and then pop the complete message, which reads faster and matches how receiving a text actually looks. Character-by-character works better for the “me” side, simulating composing a reply.

Q: How do I add photos or images inside a bubble?

Use Remotion’s <Img src={staticFile('photos/dog.jpg')} /> inside a bubble variant with reduced padding, and add an image field to your ChatMessage type. Keep images in public/ so staticFile() resolves them at render time.

Q: Can I use the exact iMessage or LINE fonts and UI?

Use the system font stack shown above rather than shipping a platform’s proprietary font, and rebuild UI elements as your own components instead of screenshotting real apps. Styles and layout conventions are fine to take inspiration from; copied assets, icons, and logos are not. If you want a specific licensed font, self-host it with @font-face — avoid loading webfonts from external CDNs, which makes renders dependent on a network request.

Q: How long should an episode be?

Most successful chat stories run 30–90 seconds per part, which at the pacing defaults above is roughly 12–25 messages. If the script runs long, split it at the strongest cliffhanger and make it a two-parter — the format rewards that.

Q: Why not just use a fake-texting mobile app?

Those tools are fine for one video. They break down on volume: no version control, no batch rendering, no reusable timing system, no way to re-render every episode after a style change. In Remotion, changing your bubble color or spring config once updates the entire back catalog on the next render.


Summary

A chat story pipeline in Remotion comes down to five pieces:

  1. A JSON script format where each message carries its sender, text, and timing
  2. A bubble component that springs in from its tail corner via spring() + interpolate()
  3. A timing helper that converts milliseconds to frame offsets — shared by visuals, audio, and duration
  4. <Sequence> orchestration inside a bottom-anchored flex column, with sound effects on the same offsets
  5. calculateMetadata for per-episode duration and a Node script that renders the whole stories/ folder

Skip the blank-canvas phase with RenderComp

The RenderComp library includes production-ready vertical social templates and UI micro-interaction components — typed props, tuned spring physics, and editable TypeScript source. Drop a template into your project, point it at your script data, and start rendering episodes today.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →