---
title: "Remotion Tutorial: Build an Animated Video with React and TypeScript"
slug: "remotion-react-video-tutorial"
published: "2026-10-01"
updated: "2026-10-06"
categories:
  - "Tools"
tags:
  - "Remotion tutorial"
  - "React video generation"
  - "Remotion TypeScript example"
  - "animated video with React"
  - "Remotion Sequence"
  - "Remotion spring animation"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "Build a Remotion video with React and TypeScript: complete code for scenes, springs, fonts, Tailwind, and MP4 exports in landscape and square formats."
---

**Summary Triples**
- (Remotion Tutorial: Build an Animated Video with React and TypeScript, expresses-intent, how-to)
- (Remotion Tutorial: Build an Animated Video with React and TypeScript, covers-topic, Remotion tutorial)
- (Remotion Tutorial: Build an Animated Video with React and TypeScript, provides-guidance-for, Build a Remotion video with React and TypeScript: complete code for scenes, springs, fonts, Tailwind, and MP4 exports in landscape and square formats.)

### {GOAL}
Build a Remotion video with React and TypeScript: complete code for scenes, springs, fonts, Tailwind, and MP4 exports in landscape and square formats.

### {PREREQS}
- Familiarity with the concepts discussed in this article.

### {STEPS}
1. Follow the detailed walkthrough in the article content below.

<!-- llm:goal="Build a Remotion video with React and TypeScript: complete code for scenes, springs, fonts, Tailwind, and MP4 exports in landscape and square formats." -->

# Remotion Tutorial: Build an Animated Video with React and TypeScript
> Build a Remotion video with React and TypeScript: complete code for scenes, springs, fonts, Tailwind, and MP4 exports in landscape and square formats.
Matija Žiberna · 2026-10-01

I wanted to turn an article about Payload CMS 4.0 into a short animated teaser. The content already had useful visual ideas: an access-control comparison, a relationship-depth diagram, and two framework adapters sharing one core. Remotion let me turn those ideas into video using React components, TypeScript, CSS, and SVG.

Here is the finished 15-second video we will recreate:

<video controls preload="metadata" poster="/remotion-guide/payload-4-0-poster.png" width="1920" height="1080">
  <source src="/remotion-guide/payload-4-0-teaser.mp4" type="video/mp4" />
  <source src="/remotion-guide/payload-4-0-teaser.webm" type="video/webm" />
  Your browser cannot play this video. <a href="/remotion-guide/payload-4-0-teaser.mp4">Download the MP4.</a>
</video>

By the end, you will have the same five-scene video in two formats: 1920×1080 landscape and 1080×1080 square. You will also understand how Remotion connects a frame number to what appears on screen, how scenes get their own timelines, and how to make fonts and images ready before a frame is captured.

You should be comfortable with React components, hooks, TypeScript, and basic CSS. You do not need a Payload installation: all the Payload content in this example is text and graphics. The canary labels describe the example's subject; this tutorial is about creating its animation.

This is a complete implementation walkthrough. Each application source file appears below. The large component is split into consecutive blocks so we can discuss each part. Copy those blocks into the same file in the order shown.

## 1. Understand what Remotion renders

<!-- bwm:slider remotion-react-video-tutorial/remotion-pipeline-scenes -->
<div data-slider="true" data-slider-id="remotion-react-video-tutorial/remotion-pipeline-scenes" data-slider-source="video/src/articles/remotion-react-video-tutorial/remotion-pipeline-scenes">
  <img src="https://img.buildwithmatija.com/api/images/b0062x4y/file/original" alt="UI = f(frame): The Mental Model: Remotion doesn't use timelines or keyframe tracks; every frame is a deterministic React render at frame N." title="UI = f(frame): The Mental Model" width="1080" height="1080" />
  <img src="https://img.buildwithmatija.com/api/images/o3bbeuvz/file/original" alt="Isolated Timelines with &lt;Sequence&gt;: Break complex animations into modular scene components with localized frame counters." title="Isolated Timelines with &lt;Sequence&gt;" width="1080" height="1080" />
  <img src="https://img.buildwithmatija.com/api/images/lf9r63si/file/original" alt="Natural Motion with spring(): Replace rigid linear easing with mass, damping, and stiffness for fluid UI physics." title="Natural Motion with spring()" width="1080" height="1080" />
  <img src="https://img.buildwithmatija.com/api/images/oorsixjy/file/original" alt="Responsive Video: 16:9 &amp; 1:1: Use responsive layout hooks to render widescreen YouTube video and square social feeds from one component." title="Responsive Video: 16:9 &amp; 1:1" width="1080" height="1080" />
</div>
<!-- /bwm:slider -->

Think of a composition as a React component with a video-sized canvas and a timeline. Remotion supplies the current frame. Your component calculates positions, opacity, scale, and visible content for that frame.

At 60 frames per second, frame 60 represents one second into the video. Our composition lasts 900 frames, so its duration is `900 / 60 = 15` seconds. Frames are zero-based: the first is 0 and the last is 899.

In Studio, you can scrub directly to a frame and see the component at that point. During a local render, Remotion uses a browser to capture frames and encodes them into a video. The component must therefore produce the right visual even when you jump directly to a later frame.

This changes how you animate. A web page often starts a CSS transition after a click and lets time pass. In a Remotion composition, the current frame determines the animation state. There is no need to play the previous 100 frames to know how frame 101 should look. Remotion explicitly recommends frame-driven animation instead of CSS transitions and keyframes. [Read the animation guidance](https://www.remotion.dev/docs/troubleshooting/css-animations).

Our implementation uses two main tools: `interpolate()` for mapping progress to visual values, and `spring()` for entrances with a natural settling motion. Ordinary CSS still handles layout, typography, borders, gradients, and colors.

## 2. Create an isolated video project

Use Node.js 24 and pnpm 10.33.0 for this walkthrough. Create a fresh directory outside any existing pnpm workspace, so its dependencies and configuration belong to this example:

```bash
mkdir remotion-payload-teaser
cd remotion-payload-teaser
mkdir -p src public/fonts out
```

All following shell commands run from this directory. Create the package file before installing anything. These are the versions used by the reference implementation; keep the Remotion packages on the same exact version.

**Create `package.json`:**

```json
{
  "name": "bwm-payload-video",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@10.33.0",
  "scripts": {
    "start": "remotion studio src/index.ts --port=3100",
    "render:landscape": "remotion render src/index.ts Payload4Teaser out/payload-4-0-teaser.mp4 --codec=h264 --pixel-format=yuv420p --crf=18 --concurrency=4 --color-space=bt709 --image-format=png",
    "render:square": "remotion render src/index.ts Payload4Square out/payload-4-0-square.mp4 --codec=h264 --pixel-format=yuv420p --crf=18 --concurrency=4 --color-space=bt709 --image-format=png",
    "render:webm": "remotion render src/index.ts Payload4Teaser out/payload-4-0-teaser.webm --codec=vp9 --video-bitrate=6M --concurrency=4 --color-space=bt709 --image-format=png",
    "poster:landscape": "remotion still src/index.ts Payload4Teaser out/payload-4-0-poster.png --frame=150",
    "poster:square": "remotion still src/index.ts Payload4Square out/payload-4-0-square-poster.png --frame=150"
  },
  "dependencies": {
    "remotion": "4.0.532",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "lucide-react": "0.475.0"
  },
  "devDependencies": {
    "@remotion/cli": "4.0.532",
    "@remotion/tailwind-v4": "4.0.532",
    "@fontsource/inter": "5.3.0",
    "@fontsource/ibm-plex-mono": "5.3.0",
    "@types/react": "19.2.2",
    "@types/react-dom": "19.2.2",
    "typescript": "5.9.3",
    "tailwindcss": "4.2.2",
    "zod": "4.5.4"
  }
}
```

Install the dependencies:

```bash
pnpm install
pnpm exec remotion versions
```

The first install generates `pnpm-lock.yaml`. Keep that lockfile with your project. Once it exists, use `pnpm install --frozen-lockfile` for repeat installations. You do not need to hand-write or copy hundreds of lockfile entries from an article.

React and React DOM render our components. Remotion supplies the timeline primitives. The CLI opens Studio and exports media. Lucide provides SVG icons, the Fontsource packages supply local font files, and the Tailwind integration processes the utility classes used by the layout. Zod is pinned in the reference package; this composition does not define a custom props schema.

The finished project will have this structure:

```text
remotion-payload-teaser/
  package.json
  pnpm-lock.yaml                 # generated by pnpm install
  remotion.config.ts
  tsconfig.json
  src/
    index.ts
    Root.tsx
    Payload4Teaser.tsx
    style.css
  public/
    bwm-logo.svg
    author.jpeg
    fonts/
      inter-400.woff2
      inter-500.woff2
      inter-600.woff2
      inter-700.woff2
      plex-mono-400.woff2
      inter-LICENSE.txt
      ibm-plex-mono-LICENSE.txt
  out/                           # generated videos and stills
```

### Configure TypeScript and Tailwind

The TypeScript configuration includes browser types because the font loader uses `FontFace` and `document.fonts`. This package is independent of any website's TypeScript configuration.

**Create `tsconfig.json`:**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["DOM", "ES2022", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src", "remotion.config.ts"]
}
```

Tailwind v4 needs a bundler integration in this project. Installing Tailwind alone does not make the classes in our components work. Use Remotion's `enableTailwind()` override. The overwrite setting allows subsequent exports to replace the same output filename. [See the official integration](https://www.remotion.dev/docs/tailwind-v4/enable-tailwind).

**Create `remotion.config.ts`:**

```ts
import { Config } from "@remotion/cli/config";
import { enableTailwind } from "@remotion/tailwind-v4";

Config.overrideWebpackConfig((config) => enableTailwind(config));
Config.setOverwriteOutput(true);
```

The stylesheet imports Tailwind, points source scanning at this directory, and uses border-box sizing. It contains no time-based CSS animation.

**Create `src/style.css`:**

```css
@import "tailwindcss";
@source './';

* {
  box-sizing: border-box;
}
```

### Prepare the images and fonts

Download the [example logo](/remotion-guide/assets/bwm-logo.svg) and [author portrait](/remotion-guide/assets/author.jpeg) into `public/`, keeping their filenames. These are the assets shown in the video. For your own video, replace them with your branding.

On macOS or Linux, you can download them from the site with:

```bash
curl --fail --location https://buildwithmatija.com/remotion-guide/assets/bwm-logo.svg --output public/bwm-logo.svg
curl --fail --location https://buildwithmatija.com/remotion-guide/assets/author.jpeg --output public/author.jpeg
```

Copy the font files from the packages you just installed, and rename them to match the paths our component will request:

```bash
for weight in 400 500 600 700; do
  cp "node_modules/@fontsource/inter/files/inter-latin-${weight}-normal.woff2" "public/fonts/inter-${weight}.woff2"
done
cp node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2 public/fonts/plex-mono-400.woff2
cp node_modules/@fontsource/inter/LICENSE public/fonts/inter-LICENSE.txt
cp node_modules/@fontsource/ibm-plex-mono/LICENSE public/fonts/ibm-plex-mono-LICENSE.txt
```

The Latin subset covers the example's text. If you adapt it to another writing system, select a font subset that includes those characters.

Remotion's `staticFile()` resolves assets from the project's `public/` directory. We will use it for both the images and fonts, rather than relying on a website-relative URL. [See `staticFile()`](https://www.remotion.dev/docs/staticfile).

## 3. Register the entrypoint and compositions

The CLI needs an entrypoint that registers our root component.

**Create `src/index.ts`:**

```ts
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";

registerRoot(RemotionRoot);
```

The root registers two compositions. Each points to the same React component, but supplies different canvas dimensions. The IDs are what the render scripts select. [See the composition API](https://www.remotion.dev/docs/composition).

**Create `src/Root.tsx`:**

```tsx
import { Composition } from "remotion";
import { Payload4Teaser } from "./Payload4Teaser";

export const RemotionRoot = () => {
  return (
    <>
      <Composition
        id="Payload4Teaser"
        component={Payload4Teaser}
        durationInFrames={900}
        fps={60}
        width={1920}
        height={1080}
        defaultProps={{
          title: "Payload CMS 4.0: Breaking Changes & Canary Deep Dive",
          slug: "payload-4-0",
        }}
      />
      <Composition
        id="Payload4Square"
        component={Payload4Teaser}
        durationInFrames={900}
        fps={60}
        width={1080}
        height={1080}
        defaultProps={{
          title: "Payload CMS 4.0: Breaking Changes & Canary Deep Dive",
          slug: "payload-4-0",
        }}
      />
    </>
  );
};
```

Both compositions run at 60 fps for 900 frames. The square video is a different layout of the same content, rather than a center crop of the landscape export.

`defaultProps` supplies the title for the opening scene and the article slug for the closing URL. These props do not make every scene generic: the access-control diagrams and other Payload-specific text remain in the component.

`Root.tsx` imports `Payload4Teaser`, which we create next. Wait until the final component is assembled before opening Studio.

## 4. Create the shared animation and layout components

Create `src/Payload4Teaser.tsx`. **Copy every TSX block from this section through section 6 into this one file, in order.** Each block adds new declarations; it does not replace the previous block. Together they are the complete source.

### Imports, colors, and layout detection

Start the file with all imports and the shared constants:

```tsx
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import {
  AbsoluteFill,
  Img,
  Sequence,
  continueRender,
  delayRender,
  cancelRender,
  interpolate,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import {
  ArrowRight,
  ArrowUpRight,
  Code2,
  Database,
  GitBranch,
  LockKeyhole,
  ShieldCheck,
  Sparkles,
  Terminal,
  Users,
  Workflow,
} from "lucide-react";
import "./style.css";

export type TeaserProps = { title: string; slug: string };
const C = {
  bg: "#0b1120",
  card: "#1e293b",
  sky: "#38bdf8",
  text: "#f1f5f9",
  muted: "#94a3b8",
  red: "#fb7185",
  green: "#6ee7b7",
  border: "#334155",
};
const mono: CSSProperties = { fontFamily: "IBM Plex Mono, monospace" };
const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
const useSquare = () => {
  const { width, height } = useVideoConfig();
  return width === height;
};
```

`C` centralizes the palette. `mono` gives code and labels a consistent font. `clamp` prevents interpolation from continuing beyond its intended endpoints. The `useSquare()` hook reads the composition dimensions; it does not use the browser window's size.

That distinction matters when the same composition is previewed at different zoom levels. A 1080×1080 canvas stays square even if Studio shows it inside a wide browser window.

### Wait for bundled fonts

Append the font gate:

```tsx
const FontGate = () => {
  const [handle] = useState(() => delayRender("Loading bundled typography"));
  useEffect(() => {
    let active = true;
    Promise.all(
      [
        ...[400, 500, 600, 700].map(
          (weight) =>
            new FontFace(
              "Inter",
              `url("${staticFile(`fonts/inter-${weight}.woff2`)}")`,
              { weight: String(weight) },
            ),
        ),
        new FontFace(
          "IBM Plex Mono",
          `url("${staticFile("fonts/plex-mono-400.woff2")}")`,
          { weight: "400" },
        ),
      ].map(async (font) => {
        const loaded = await font.load();
        document.fonts.add(loaded);
      }),
    )
      .then(() => {
        if (active) continueRender(handle);
      })
      .catch(cancelRender);
    return () => {
      active = false;
    };
  }, [handle]);
  return null;
};
```

The gate creates a render-delay handle once for the mounted component. It loads the required weights, registers them with the browser, and clears the handle when all fonts are ready. A load failure calls `cancelRender`, so an export fails rather than silently capturing fallback typography. [See the render-delay lifecycle](https://www.remotion.dev/docs/delay-render).

The component renders nothing visible. Its purpose is to coordinate readiness. Loading the typefaces matters because fallback fonts can change text widths, line breaks, and diagram spacing.

The cleanup flag prevents a completed request from continuing a render after this instance has unmounted. For a larger application, Remotion also provides font-loading utilities; here we retain the small `FontFace` implementation used by the reference video. [Read the font guide](https://www.remotion.dev/docs/fonts).

### Badges and staggered entrances

Append the badge component:

```tsx
const Badge = ({
  children,
  color = C.sky,
  style,
}: {
  children: ReactNode;
  color?: string;
  style?: CSSProperties;
}) => (
  <div
    style={{
      ...mono,
      display: "inline-flex",
      alignItems: "center",
      gap: 12,
      padding: "12px 18px",
      fontSize: 22,
      color,
      background: `${color}10`,
      border: `1px solid ${color}40`,
      borderRadius: 12,
      whiteSpace: "nowrap",
      ...style,
    }}
  >
    {children}
  </div>
);
```

A badge is ordinary JSX and CSS. Its children can be text, an icon, or both. The same primitive styles the opening tags and the canary indicator.

Now append the entrance animation:

```tsx
const Pop = ({
  children,
  delay = 0,
  style,
}: {
  children: ReactNode;
  delay?: number;
  style?: CSSProperties;
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const progress = spring({
    frame: frame - delay,
    fps,
    config: { damping: 22, stiffness: 120, mass: 0.8 },
  });
  return (
    <div
      style={{
        opacity: interpolate(frame - delay, [-8, 16], [0, 1], clamp),
        transform: `translateY(${(1 - progress) * 28}px) scale(${0.97 + progress * 0.03})`,
        ...style,
      }}
    >
      {children}
    </div>
  );
};
```

`Pop` combines two drivers. A spring moves the element from 28 pixels below its resting position and scales it from roughly 0.97 toward 1. Interpolation controls opacity. `delay` shifts both calculations, allowing a heading, subtitle, and badges to enter in succession.

The spring receives the composition's `fps`; the damping, stiffness, and mass values tune how it settles. The frame passed to it is `frame - delay`, so each element uses the same entrance with a different start. [See the spring API](https://www.remotion.dev/docs/spring).

There is a subtle detail in the opacity curve: it maps `[-8, 16]` to `[0, 1]`. That starts fading eight frames before the spring's nominal start and reaches full opacity sixteen frames after it. `delay` therefore does not mean completely invisible until that exact frame.

Clamping keeps opacity within the chosen range before and after the animation interval. Without clamping, interpolation continues along the line beyond its endpoints. [See interpolation and extrapolation](https://www.remotion.dev/docs/interpolate).

### Scene framing and headings

Append the shared scene wrapper:

```tsx
const Scene = ({
  children,
  duration = 180,
  hold = false,
}: {
  children: ReactNode;
  duration?: number;
  hold?: boolean;
}) => {
  const frame = useCurrentFrame();
  const sq = useSquare();
  return (
    <AbsoluteFill
      style={{
        padding: sq ? "208px 68px 140px" : "220px 112px 140px",
        opacity: interpolate(
          frame,
          [0, 16, duration - 13, duration - 1],
          [0.35, 1, 1, hold ? 1 : 0.35],
          clamp,
        ),
        transform: `translateY(${interpolate(frame, [0, 24, duration - 13, duration - 1], [14, 0, 0, hold ? 0 : -8], clamp)}px)`,
      }}
    >
      {children}
    </AbsoluteFill>
  );
};
```

`AbsoluteFill` covers the composition. The wrapper supplies padding, a short entrance, and a short exit based on the scene's local frame. Its default duration is 180 frames; the longer access scene and shorter depth scene pass explicit durations.

This example fades between opacity 0.35 and 1, rather than fading completely to black. The final scene uses `hold` to keep its end state visible. These are design choices you can change by adjusting the output ranges.

Append the shared heading:

```tsx
const Heading = ({
  index,
  eyebrow,
  children,
  subtitle,
}: {
  index: string;
  eyebrow: string;
  children: ReactNode;
  subtitle?: ReactNode;
}) => {
  const sq = useSquare();
  return (
    <div style={{ marginBottom: sq ? 30 : 42 }}>
      <div
        style={{
          ...mono,
          color: C.sky,
          fontSize: 23,
          letterSpacing: 3,
          textTransform: "uppercase",
          marginBottom: 16,
        }}
      >
        {index} / {eyebrow}
      </div>
      <h1
        style={{
          fontSize: sq ? 56 : 76,
          fontWeight: 600,
          letterSpacing: -3,
          lineHeight: 1.1,
          margin: 0,
        }}
      >
        {children}
      </h1>
      {subtitle && (
        <div
          style={{
            fontSize: sq ? 25 : 28,
            color: C.muted,
            lineHeight: 1.5,
            marginTop: 18,
          }}
        >
          {subtitle}
        </div>
      )}
    </div>
  );
};
```

The heading changes font sizes and spacing for the square composition. `ReactNode` lets subtitles contain either plain text or JSX, such as a highlighted value.

### Persistent background and branding

Append the background:

```tsx
const Background = () => {
  const frame = useCurrentFrame();
  const { width } = useVideoConfig();
  return (
    <AbsoluteFill style={{ background: C.bg, overflow: "hidden" }}>
      <AbsoluteFill
        style={{
          background:
            "radial-gradient(ellipse at 82% 18%, #38bdf817 0%, transparent 52%), radial-gradient(ellipse at 12% 90%, #2563eb10 0%, transparent 45%)",
        }}
      />
      <AbsoluteFill
        style={{
          opacity: 0.15,
          backgroundImage:
            "linear-gradient(#334155 1px, transparent 1px), linear-gradient(90deg, #334155 1px, transparent 1px)",
          backgroundSize: "80px 80px",
          transform: `translateY(${(frame / 30) % 80}px)`,
          top: -80,
          bottom: -80,
        }}
      />
      <div
        style={{
          position: "absolute",
          width: 630,
          height: 630,
          border: "1px solid #38bdf814",
          borderRadius: "50%",
          right: -170,
          top: -180,
          transform: `scale(${1 + frame / 18000})`,
        }}
      />
      <div
        style={{
          position: "absolute",
          width: 840,
          height: 840,
          border: "1px solid #38bdf80b",
          borderRadius: "50%",
          right: -275,
          top: -285,
        }}
      />
      <div
        style={{
          position: "absolute",
          bottom: 0,
          left: 0,
          height: 5,
          width: (width * (frame + 1)) / 900,
          background: C.sky,
          boxShadow: "0 0 24px #38bdf880",
        }}
      />
    </AbsoluteFill>
  );
};
```

The gradient, grid, rings, and progress bar remain behind every scene. Their movement comes from the current frame. The grid translates using a repeating calculation, and the large ring slowly scales.

The progress bar uses `(frame + 1) / 900`, so it reaches full width on the last frame, 899. The denominator is deliberately tied to this video's fixed duration. If you change the composition length, update it too, or derive it from `useVideoConfig()`.

Append the brand overlay:

```tsx
const Brand = () => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  const chapter =
    frame < 180
      ? "THE V4 BREAKDOWN"
      : frame < 420
        ? "SECURITY DEFAULTS"
        : frame < 540
          ? "QUERY DEPTH"
          : frame < 720
            ? "FRAMEWORK ADAPTERS"
            : "THE FULL GUIDE";
  return (
    <>
      <div
        className="flex items-center justify-between"
        style={{
          position: "absolute",
          top: sq ? 66 : 64,
          left: sq ? 68 : 112,
          right: sq ? 68 : 112,
          height: 64,
        }}
      >
        <div className="flex items-center" style={{ gap: 18 }}>
          <Img
            src={staticFile("bwm-logo.svg")}
            style={{ width: 54, height: 54, borderRadius: 9 }}
          />
          <div
            style={{
              fontSize: sq ? 24 : 26,
              letterSpacing: -0.7,
              fontWeight: 600,
            }}
          >
            Build with Matija<span style={{ color: C.sky }}>.</span>
          </div>
        </div>
        <Badge style={{ fontSize: sq ? 17 : 19, padding: "10px 14px" }}>
          <span
            style={{
              width: 7,
              height: 7,
              borderRadius: "50%",
              background: C.sky,
            }}
          />
          V4 CANARY
        </Badge>
      </div>
      <div
        style={{
          position: "absolute",
          bottom: 56,
          left: sq ? 68 : 112,
          right: sq ? 68 : 112,
          display: "flex",
          justifyContent: "space-between",
          color: C.muted,
          ...mono,
          fontSize: sq ? 17 : 20,
          letterSpacing: 1.2,
        }}
      >
        <span>{chapter}</span>
        <span>buildwithmatija.com</span>
      </div>
    </>
  );
};
```

The chapter labels switch at the same frame boundaries we will use for the scenes. The logo, canary badge, chapter label, and website address stay visible across the whole timeline.

We will place `Background` and `Brand` outside the sequences. They therefore receive the composition's frame, while the scene components inside a sequence receive a local frame. That lets the background stay continuous while each scene starts its own entrance at zero.

## 5. Build the five scenes

Continue appending these blocks to `src/Payload4Teaser.tsx`. We are now composing the shared primitives into the exact content shown in the video.

### Scene 1: title and hook

Append the opening scene:

```tsx
const Hook = ({ title }: { title: string }) => {
  const sq = useSquare();
  const separator = title.indexOf(":");
  const headline = separator < 0 ? title : title.slice(0, separator);
  const subtitle =
    separator < 0
      ? "Breaking Changes & Canary Deep Dive"
      : title.slice(separator + 1).trim();
  return (
    <Scene>
      <Pop>
        <div
          style={{
            ...mono,
            fontSize: 23,
            color: C.sky,
            letterSpacing: 3,
            marginBottom: sq ? 36 : 34,
          }}
        >
          ARCHITECTURE / MIGRATION / WHAT’S NEXT
        </div>
      </Pop>
      <Pop delay={8}>
        <h1
          style={{
            fontSize: sq ? 104 : 146,
            lineHeight: 1.06,
            fontWeight: 600,
            letterSpacing: sq ? -6 : -8,
            margin: 0,
            maxWidth: sq ? 900 : 1600,
          }}
        >
          {headline.replace(" 4.0", "")}
          <br />
          <span style={{ color: C.sky }}>4.0</span>
          <span
            style={{
              fontSize: sq ? 27 : 32,
              letterSpacing: 0,
              color: C.muted,
              marginLeft: 28,
              verticalAlign: "middle",
              ...mono,
            }}
          >
            THE NEXT CHAPTER
          </span>
        </h1>
      </Pop>
      <Pop delay={20}>
        <div
          style={{
            fontSize: sq ? 40 : 49,
            lineHeight: 1.25,
            fontWeight: 500,
            letterSpacing: -1.3,
            maxWidth: sq ? 820 : 1400,
            marginTop: sq ? 28 : 32,
          }}
        >
          {subtitle}
        </div>
      </Pop>
      <div
        className="flex flex-wrap"
        style={{ gap: 14, maxWidth: sq ? 810 : 1550, marginTop: sq ? 40 : 50 }}
      >
        {[
          "Node 24",
          "Next.js 16",
          "TanStack Start",
          "overrideAccess: false",
        ].map((tag, i) => (
          <Pop key={tag} delay={32 + i * 8}>
            <Badge style={{ fontSize: sq ? 23 : 25 }}>{tag}</Badge>
          </Pop>
        ))}
      </div>
      <Pop delay={70}>
        <div style={{ color: C.muted, fontSize: sq ? 24 : 26, marginTop: 30 }}>
          A closer look at the defaults that change your code.
        </div>
      </Pop>
    </Scene>
  );
};
```

The title is split at its first colon. The first part becomes the headline, and the rest becomes the subtitle. The highlighted `4.0` and the supporting labels are specific to this example.

The tags use delays of `32 + i * 8` frames, so each starts eight frames after the previous tag. At 60 fps that spacing is about 0.13 seconds. The animation stays deterministic because the delay comes from the array index.

### Scene 2: compare access defaults

Append the comparison card and its scene:

```tsx
const AccessCard = ({ v4 }: { v4: boolean }) => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  const color = v4 ? C.green : C.red;
  const draw = interpolate(frame, [v4 ? 58 : 25, v4 ? 100 : 67], [0, 1], clamp);
  return (
    <div
      style={{
        background: "linear-gradient(135deg, #1e293b, #152033)",
        border: `1px solid ${color}45`,
        borderRadius: 24,
        padding: sq ? "20px 28px" : "32px 36px",
        flex: 1,
        position: "relative",
        lineHeight: 1.2,
        boxShadow: "0 18px 60px #00000020",
      }}
    >
      <div className="flex items-center justify-between">
        <div style={{ ...mono, fontSize: sq ? 20 : 24, color: C.muted }}>
          PAYLOAD {v4 ? "4" : "3"}
        </div>
        <div style={{ color }}>
          {sq ? (
            <span style={{ ...mono, fontSize: 21 }}>
              overrideAccess: {v4 ? "false" : "true"}
            </span>
          ) : (
            <ShieldCheck size={32} />
          )}
        </div>
      </div>
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 20,
          marginTop: sq ? 12 : 28,
        }}
      >
        <Terminal color={C.muted} size={sq ? 28 : 36} />
        <svg width={sq ? 140 : 180} height="28" viewBox="0 0 180 28">
          <path
            d="M0 14 H164"
            pathLength={1}
            stroke={color}
            strokeWidth={2}
            fill="none"
            strokeDasharray="1"
            strokeDashoffset={1 - draw}
          />
          <path
            d="M156 6 L164 14 L156 22"
            stroke={color}
            strokeWidth={2}
            fill="none"
            opacity={draw}
          />
        </svg>
        <div style={{ color }}>
          {v4 ? (
            <LockKeyhole size={sq ? 28 : 36} />
          ) : (
            <Database size={sq ? 28 : 36} />
          )}
        </div>
        <span style={{ ...mono, color, fontSize: sq ? 21 : 23 }}>
          {v4 ? "RULES EVALUATED" : "RULES BYPASSED"}
        </span>
      </div>
      <div
        style={{
          fontSize: sq ? 30 : 42,
          fontWeight: 600,
          letterSpacing: -1.2,
          marginTop: sq ? 12 : 28,
        }}
      >
        {v4 ? "Access enforced by default" : "Implicit superadmin"}
      </div>
      {!sq && (
        <div style={{ ...mono, fontSize: 26, color, marginTop: 16 }}>
          overrideAccess: {v4 ? "false" : "true"}
        </div>
      )}
      <div
        style={{
          fontSize: sq ? 21 : 25,
          color: C.muted,
          marginTop: sq ? 12 : 24,
        }}
      >
        {v4 ? "Allow · Filter · Deny" : "Collection access checks skipped"}
      </div>
    </div>
  );
};

const Access = () => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  return (
    <Scene duration={240}>
      <Heading
        index="01"
        eyebrow="Secure by default"
        subtitle={<>Same Local API query. Different default.</>}
      >
        The rules now apply.
      </Heading>
      <Pop delay={4}>
        <div
          style={{
            ...mono,
            fontSize: sq ? 25 : 29,
            background: "#02061780",
            border: `1px solid ${C.border}`,
            borderRadius: 14,
            padding: "18px 24px",
            marginBottom: sq ? 22 : 30,
            color: C.text,
          }}
        >
          payload.find({"{"} collection:{" "}
          <span style={{ color: C.sky }}>'orders'</span> {"}"})
          <span
            style={{
              color: C.muted,
              fontSize: sq ? 18 : 22,
              display: sq ? "block" : "inline",
              marginLeft: sq ? 0 : 22,
              marginTop: sq ? 8 : 0,
            }}
          >
            // overrideAccess omitted
          </span>
        </div>
      </Pop>
      <div
        style={{
          display: "flex",
          flexDirection: sq ? "column" : "row",
          gap: sq ? 18 : 28,
          transform: `scale(${interpolate(frame, [50, 190], [1, 1.012], clamp)})`,
          transformOrigin: "center",
        }}
      >
        <Pop delay={12} style={{ flex: 1, display: "flex" }}>
          <AccessCard v4={false} />
        </Pop>
        <Pop delay={42} style={{ flex: 1, display: "flex" }}>
          <AccessCard v4 />
        </Pop>
      </div>
      <Pop delay={90}>
        <div
          style={{
            color: C.muted,
            fontSize: sq ? 21 : 25,
            marginTop: sq ? 20 : 28,
          }}
        >
          No user? Public access rules apply.
        </div>
      </Pop>
    </Scene>
  );
};
```

The two cards share one component. The `v4` flag changes the labels, colors, icons, and connector timing. For the animated SVG line, `pathLength={1}` normalizes the path length and `strokeDashoffset={1 - draw}` reveals it as `draw` moves toward 1.

The square layout stacks the cards and moves the access-default text into each header. That removes repeated detail from the taller layout and keeps room for the query, heading, and footer. Simply changing the canvas size would not make the landscape scene fit.

The scene lasts 240 frames, which is four seconds. Its card entrances use local frame values, so their delays are measured from the start of this scene, not from frame zero of the full video.

The example's message distinguishes access checks being evaluated from a request being granted. The green card still lists Allow, Filter, and Deny; the diagram should not imply that evaluating rules always allows access.

### Scene 3: relationship depth

Append the relationship row and the depth scene:

```tsx
const RelationshipRow = ({ v4 }: { v4: boolean }) => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  const draw = interpolate(frame, [v4 ? 25 : 8, v4 ? 65 : 42], [0, 1], clamp);
  const labels = ["Post", "Author", v4 ? "Avatar ID" : "Avatar document"];
  const icons = [
    <Code2 key="post" size={32} />,
    <Users key="author" size={32} />,
    <Database key="avatar" size={32} />,
  ];
  return (
    <div
      style={{
        padding: sq ? "26px 24px" : "30px 36px",
        border: `1px solid ${v4 ? C.sky + "60" : C.border}`,
        borderRadius: 24,
        background: v4 ? "#12253b" : "#1e293b90",
        lineHeight: 1.2,
      }}
    >
      <div
        style={{
          ...mono,
          color: v4 ? C.sky : C.muted,
          fontSize: 23,
          marginBottom: 24,
        }}
      >
        PAYLOAD {v4 ? "4" : "3"}{" "}
        <span style={{ color: C.muted }}>
          {" "}
          / DEFAULT DEPTH: {v4 ? "1" : "2"}
        </span>
      </div>
      <div className="flex items-center" style={{ gap: sq ? 10 : 24 }}>
        {labels.map((label, i) => (
          <div key={label} style={{ display: "contents" }}>
            {i > 0 && (
              <svg
                width={sq ? 60 : 140}
                height="30"
                viewBox="0 0 140 30"
                style={{ flexShrink: 0 }}
              >
                <path
                  d="M0 15 H126"
                  stroke={v4 && i === 2 ? C.muted : C.sky}
                  strokeWidth={2}
                  strokeDasharray={v4 && i === 2 ? "8 7" : undefined}
                  opacity={draw}
                />
                <path
                  d="M118 7 L126 15 L118 23"
                  stroke={v4 && i === 2 ? C.muted : C.sky}
                  strokeWidth={2}
                  fill="none"
                  opacity={draw}
                />
              </svg>
            )}
            <div
              style={{
                flex: 1,
                padding: sq ? "22px 12px" : "26px 30px",
                border: `1px ${v4 && i === 2 ? "dashed" : "solid"} ${C.border}`,
                background: "#0b1120a0",
                borderRadius: 14,
                display: "flex",
                flexDirection: sq ? "column" : "row",
                alignItems: "center",
                gap: 16,
                color: v4 && i === 2 ? C.muted : C.text,
              }}
            >
              <span style={{ color: i === 2 && v4 ? C.muted : C.sky }}>
                {icons[i]}
              </span>
              <span
                style={{
                  fontSize: sq ? 25 : 32,
                  fontWeight: 500,
                  textAlign: "center",
                  whiteSpace: "nowrap",
                }}
              >
                {label}
              </span>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

const Depth = () => (
  <Scene duration={120}>
    <Heading
      index="02"
      eyebrow="Leaner queries"
      subtitle="One fewer populated relationship level."
    >
      Default depth: <span style={{ color: C.muted }}>2</span>{" "}
      <span style={{ color: C.sky }}>→ 1</span>
    </Heading>
    <div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
      <Pop>
        <RelationshipRow v4={false} />
      </Pop>
      <Pop delay={18}>
        <RelationshipRow v4 />
      </Pop>
    </div>
    <Pop delay={45}>
      <div style={{ marginTop: 28, fontSize: 25, color: C.muted }}>
        Need the avatar document? Set{" "}
        <span style={{ ...mono, color: C.sky }}>depth: 2</span>.
      </div>
    </Pop>
  </Scene>
);
```

This scene uses two rows to compare the diagrams. The last node is a populated document in one row and an ID in the other. Dashed styling carries that distinction even without relying on color.

The node contents are React components, including Lucide SVG icons. The connectors are SVG paths. Remotion can animate them because their visible properties are calculated from frames, just like the earlier HTML cards.

`Scene duration={120}` keeps this scene's entrance and exit aligned with its two-second sequence. The second row and explanatory note arrive later through `Pop` delays.

### Scene 4: framework adapters and the shared core

Append the framework scene:

```tsx
const Frameworks = () => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  const progress = interpolate(frame, [25, 72], [0, 1], clamp);
  return (
    <Scene>
      <Heading index="03" eyebrow="Framework boundary">
        Two paths. One core.
      </Heading>
      <div style={{ position: "relative" }}>
        <div className="flex" style={{ gap: 24 }}>
          {[false, true].map((tanstack) => (
            <Pop
              key={String(tanstack)}
              delay={tanstack ? 12 : 0}
              style={{ flex: 1 }}
            >
              <div
                style={{
                  padding: sq ? "26px 24px" : "24px 36px",
                  lineHeight: 1.2,
                  background: "#1e293bcc",
                  border: `1px ${tanstack ? "dashed" : "solid"} ${tanstack ? "#38bdf870" : C.border}`,
                  borderRadius: 24,
                }}
              >
                <div
                  style={{
                    display: "flex",
                    gap: 16,
                    alignItems: "center",
                    color: C.sky,
                  }}
                >
                  {tanstack ? <GitBranch size={32} /> : <Workflow size={32} />}
                  <span
                    style={{
                      fontSize: sq ? 30 : 38,
                      fontWeight: 600,
                      letterSpacing: -1,
                    }}
                  >
                    {tanstack ? "TanStack Start" : "Next.js"}
                  </span>
                </div>
                <div
                  style={{
                    ...mono,
                    color: C.muted,
                    fontSize: sq ? 22 : 25,
                    marginTop: 18,
                  }}
                >
                  {tanstack ? "Vite / Router" : "App Router / RSC"}
                </div>
                <div
                  style={{
                    color: tanstack ? C.sky : C.muted,
                    fontSize: sq ? 21 : 23,
                    marginTop: 16,
                  }}
                >
                  {tanstack ? "Experimental canary" : "Established adapter"}
                </div>
              </div>
            </Pop>
          ))}
        </div>
        <svg
          width="100%"
          height={sq ? 96 : 116}
          viewBox="0 0 1000 116"
          preserveAspectRatio="none"
          style={{ display: "block" }}
        >
          <path
            d="M245 0 V48 Q245 58 255 58 H490 Q500 58 500 68 V106"
            fill="none"
            stroke={C.sky}
            strokeWidth={2}
            pathLength={1}
            strokeDasharray="1"
            strokeDashoffset={1 - progress}
          />
          <path
            d="M755 0 V48 Q755 58 745 58 H500 V106"
            fill="none"
            stroke={C.sky}
            strokeWidth={2}
            strokeDasharray="8 7"
            opacity={progress}
          />
          <path
            d="M490 96 L500 106 L510 96"
            fill="none"
            stroke={C.sky}
            strokeWidth={2}
            opacity={progress}
          />
        </svg>
        <Pop delay={30}>
          <div
            style={{
              border: "1px solid #38bdf870",
              borderRadius: 24,
              padding: sq ? "28px" : "26px 42px",
              lineHeight: 1.2,
              background: "linear-gradient(120deg, #0c2840, #122036)",
              display: "flex",
              alignItems: "center",
              gap: 24,
            }}
          >
            <Database size={sq ? 48 : 62} color={C.sky} />
            <div>
              <div
                style={{
                  fontSize: sq ? 39 : 49,
                  fontWeight: 600,
                  letterSpacing: -1.5,
                }}
              >
                Payload 4 Core
              </div>
              <div
                style={{
                  fontSize: sq ? 22 : 26,
                  marginTop: 12,
                  color: C.muted,
                }}
              >
                Local API · Collections · Auth · Database adapters
              </div>
            </div>
          </div>
        </Pop>
      </div>
      <Pop delay={58}>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 18,
            marginTop: sq ? 30 : 28,
          }}
        >
          <Sparkles color={C.sky} size={30} />
          <div>
            <div style={{ fontSize: sq ? 29 : 34, fontWeight: 500 }}>
              Zero Sass. Pure CSS variables.
            </div>
            <div style={{ fontSize: 22, color: C.muted, marginTop: 8 }}>
              Semantic tokens for the refreshed admin.
            </div>
          </div>
        </div>
      </Pop>
    </Scene>
  );
};
```

Both framework cards point toward one Payload core. The Next.js path is solid, while the TanStack Start card and path retain the example's experimental-canary styling.

The solid connector uses the same normalized path-length technique as the access cards. The dashed connector fades in through `opacity={progress}`. The SVG view box provides a shared coordinate system while its rendered width follows the composition.

The scene is ordinary React composition: two cards, a connector diagram, a core card, and an admin-style note. The motion is concentrated in entrances and connector reveals, making the content easier to read during its three seconds on screen.

### Scene 5: hold the call to action

Append the closing scene:

```tsx
const CTA = ({ slug }: { slug: string }) => {
  const sq = useSquare();
  const frame = useCurrentFrame();
  return (
    <Scene hold>
      <div
        style={{
          transform: `scale(${interpolate(frame, [0, 48], [1.025, 1], clamp)})`,
          transformOrigin: "center",
        }}
      >
        <Pop>
          <div
            style={{
              ...mono,
              color: C.sky,
              fontSize: 23,
              letterSpacing: 3,
              marginBottom: 34,
            }}
          >
            YOUR NEXT MIGRATION STARTS HERE
          </div>
        </Pop>
        <Pop delay={6}>
          <h1
            style={{
              fontSize: sq ? 83 : 112,
              letterSpacing: -5,
              fontWeight: 600,
              lineHeight: 1.09,
              margin: 0,
            }}
          >
            Read the full guide
            <br />
            <span style={{ color: C.sky }}>
              & migration
              <br className={sq ? "" : "hidden"} /> breakdown.
            </span>
          </h1>
        </Pop>
        <Pop delay={18}>
          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: 20,
              marginTop: sq ? 34 : 42,
              padding: sq ? "22px 24px" : "24px 30px",
              border: "1px solid #38bdf860",
              borderRadius: 18,
              background: "#38bdf80d",
              maxWidth: sq ? 940 : 1410,
            }}
          >
            <ArrowUpRight color={C.sky} size={36} />
            <div style={{ ...mono, fontSize: sq ? 28 : 33, lineHeight: 1.5 }}>
              <span>buildwithmatija.com</span>
              <span style={{ color: C.sky, display: sq ? "block" : "inline" }}>
                /blog/{slug}
              </span>
            </div>
          </div>
        </Pop>
        <Pop delay={30}>
          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: 22,
              marginTop: sq ? 34 : 44,
            }}
          >
            <Img
              src={staticFile("author.jpeg")}
              style={{
                width: 74,
                height: 74,
                borderRadius: "50%",
                objectFit: "cover",
                border: "2px solid #334155",
              }}
            />
            <div>
              <div style={{ fontSize: 29, fontWeight: 500 }}>
                Matija Žiberna
              </div>
              <div style={{ fontSize: 22, color: C.muted, marginTop: 7 }}>
                Build with Matija
              </div>
            </div>
            <div style={{ marginLeft: "auto", color: C.sky }}>
              <ArrowRight size={sq ? 46 : 62} strokeWidth={1.5} />
            </div>
          </div>
        </Pop>
      </div>
    </Scene>
  );
};
```

The closing URL uses the `slug` prop. Its square variant puts the path on a second line and wraps the heading deliberately. The portrait uses Remotion's `Img` component with `staticFile()`, and the fixed display dimensions keep the surrounding layout stable.

`Scene hold` keeps the completed CTA at full opacity. Its small scale change settles during the first 48 frames, leaving time to read the URL before the video ends.

## 6. Assemble the timeline

Append the final exported component. This completes `src/Payload4Teaser.tsx`:

```tsx
export const Payload4Teaser = ({ title, slug }: TeaserProps) => (
  <AbsoluteFill
    style={{ fontFamily: "Inter, sans-serif", color: C.text, background: C.bg }}
  >
    <FontGate />
    <Background />
    <Sequence from={0} durationInFrames={180} name="Hook & title">
      <Hook title={title} />
    </Sequence>
    <Sequence from={180} durationInFrames={240} name="Access defaults">
      <Access />
    </Sequence>
    <Sequence from={420} durationInFrames={120} name="Relationship depth">
      <Depth />
    </Sequence>
    <Sequence from={540} durationInFrames={180} name="Frameworks & admin">
      <Frameworks />
    </Sequence>
    <Sequence from={720} durationInFrames={180} name="Read the guide">
      <CTA slug={slug} />
    </Sequence>
    <Brand />
  </AbsoluteFill>
);
```

The order of the layers matters: font coordination first, then the background, then the timed scenes, then branding above them.

Here is the full frame budget:

| Scene | Start frame | Duration | Last frame | Time interval |
| --- | ---: | ---: | ---: | --- |
| Hook and title | 0 | 180 | 179 | 0–3 seconds |
| Access defaults | 180 | 240 | 419 | 3–7 seconds |
| Relationship depth | 420 | 120 | 539 | 7–9 seconds |
| Frameworks and admin | 540 | 180 | 719 | 9–12 seconds |
| Call to action | 720 | 180 | 899 | 12–15 seconds |

A sequence's `durationInFrames` is a count. The first scene includes frames 0 through 179; frame 180 belongs to the next scene. These five intervals cover all 900 frames without a gap or overlap.

Inside `<Sequence from={180}>`, `useCurrentFrame()` returns 0 at composition frame 180, 1 at frame 181, and so on. That is why the access cards can use small delays such as 12 and 42: they are relative to that scene. [See sequence timing](https://www.remotion.dev/docs/sequence).

The background and brand are outside those sequences and keep the global frame number. Moving them inside a scene would reset their timing when the scene starts.

A sequence also controls when its children appear. You do not need to hide every scene manually with conditions; the sequence supplies the visibility window and shifted timeline.

## 7. Preview the result in Studio

Check TypeScript and start Studio:

```bash
npx tsc --noEmit
pnpm start
```

Open `http://localhost:3100` and select `Payload4Teaser`. Play the composition, then switch to `Payload4Square`. If the port is busy, use:

```bash
pnpm exec remotion studio src/index.ts --port=3103
```

Use representative frames to inspect each scene: 150, 340, 490, 660, and 850. Also inspect both sides of every boundary: 179/180, 419/420, 539/540, and 719/720. Check the opening frame 0 and the final frame 899.

At each stop, check whether headings fit, connectors point to the intended nodes, and the persistent footer stays clear. Preview the square composition around social-feed display sizes as well as at full resolution. Large export dimensions do not guarantee readable text on a phone.

You can save an individual frame for a closer look:

```bash
pnpm exec remotion still src/index.ts Payload4Square out/check-square-access.png --frame=340
```

Do not judge a composition only by one attractive poster. The frame before a scene change and the first frame after it reveal timing mistakes that a settled frame can hide.

## 8. Render MP4, WebM, and posters

The scripts in `package.json` already specify the entrypoint, composition ID, and output filename. Run them from the example project:

```bash
pnpm render:landscape
pnpm render:square
pnpm render:webm
pnpm poster:landscape
pnpm poster:square
```

The first render may download Chrome Headless Shell. Let that finish before diagnosing a slow first export as an animation problem. [See the rendering walkthrough](https://www.remotion.dev/docs/render).

The MP4 scripts use H.264, `yuv420p`, CRF 18, and BT.709 color space. PNG frame capture avoids introducing JPEG artifacts before encoding around text and thin diagram strokes. `--concurrency=4` is the reference setting; reduce it if your machine struggles with memory. The WebM script uses VP9 at a target bitrate of 6 Mbps. [See the CLI options](https://www.remotion.dev/docs/cli/render).

Posters come from frame 150, 2.5 seconds into the composition, when the opening scene has settled. You can choose another frame by changing `--frame` in the poster scripts.

Expect these outputs:

| Output | Canvas | Purpose |
| --- | --- | --- |
| `out/payload-4-0-teaser.mp4` | 1920×1080 | Landscape H.264 video |
| `out/payload-4-0-square.mp4` | 1080×1080 | Square H.264 video |
| `out/payload-4-0-teaser.webm` | 1920×1080 | Landscape VP9 alternative |
| `out/payload-4-0-poster.png` | 1920×1080 | Landscape poster |
| `out/payload-4-0-square-poster.png` | 1080×1080 | Square poster |

Both video compositions are 15 seconds at 60 fps. This example has no audio track; silent playback is expected.

Here is the square reference export for comparison:

<video controls preload="metadata" poster="/remotion-guide/payload-4-0-square-poster.png" width="1080" height="1080">
  <source src="/remotion-guide/payload-4-0-square.mp4" type="video/mp4" />
  Your browser cannot play this video. <a href="/remotion-guide/payload-4-0-square.mp4">Download the square MP4.</a>
</video>

If you have `ffprobe` installed, inspect the actual encoded media rather than relying only on composition settings:

```bash
ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=codec_name,width,height,r_frame_rate,nb_read_frames,pix_fmt,color_space:format=duration -of json out/payload-4-0-teaser.mp4
```

For the landscape MP4, expect H.264, 1920×1080, `60/1` fps, 900 decoded frames, approximately 15 seconds, `yuv420p`, and `bt709`. Check the square file separately; it should report 1080×1080.

Play the final files and seek into each scene. Studio confirms the React composition, while playback confirms the file you will actually share.

## 9. Adapt the example to your own content

Start by changing the title and slug in both compositions. The title controls the opening heading and subtitle; the slug controls the closing article path. Then change the scene content, logo, portrait, and brand labels.

There are several deliberate fixed values to revisit:

- `Hook` always renders a highlighted `4.0`, and its tags refer to Payload's ecosystem.
- `AccessCard`, `RelationshipRow`, and `Frameworks` contain example-specific diagrams and labels.
- `Brand` contains fixed chapter boundaries and website text.
- `Background` calculates progress against 900 frames.
- `useSquare()` recognizes a square canvas. A vertical 1080×1920 composition needs its own layout decisions.

Changing `defaultProps` alone does not turn this into a general-purpose video generator. To make it reusable across articles, extract the scene content into typed props after you have a second real example and know what varies.

If you change the duration, update the composition, sequence starts and lengths, `Scene` durations, chapter thresholds, and progress denominator together. A duration change at only the root will either cut content short or leave extra time after the intended ending.

Keep animated values derived from frames. If you add particles or randomly placed decorations, use Remotion's seeded `random()` rather than `Math.random()` so the placement is repeatable. [See deterministic randomness](https://www.remotion.dev/docs/random).

## 10. Troubleshoot the common failures

### A component or import cannot be found

Make sure you copied every consecutive block into `src/Payload4Teaser.tsx`, including the imports at the top and exported component at the bottom. The blocks are parts of one file. Copying only an individual scene will leave its helpers undefined.

Run `npx tsc --noEmit` from the example directory. If the error mentions `FontFace` or `document.fonts`, compare the TypeScript `lib` array with the configuration above.

### Utility classes have no effect

Check that the component imports `./style.css`, that the stylesheet imports Tailwind, and that `remotion.config.ts` calls `enableTailwind`. Our layout mixes utility classes and inline styles; missing Tailwind can break flex layouts even while colors still appear correctly.

### Fonts fail to load or the render times out

Check all five requested font filenames, their copied paths, and the spelling of `public/fonts`. Use Studio's browser console to look for failed asset requests. The font gate intentionally cancels on a loading error.

A timeout can indicate work that never cleared its delay handle. Increasing the timeout does not repair a missing file or a stalled request. Trace the operation and make sure it either completes with `continueRender()` or fails with `cancelRender()`. [See render-delay troubleshooting](https://www.remotion.dev/docs/delay-render).

### Animation looks different after scrubbing

Look for CSS keyframes, transitions, timers, or values calculated from wall-clock time. A composition must display the intended result when you jump straight to a frame. Calculate motion from `useCurrentFrame()` and keep your asset and input data stable.

### Square content clips or collides with the footer

Inspect the entire scene's height, including headings, margins, query text, and notes. The reference square layout stacks access cards, tightens their spacing, and moves detail into their headers. Smaller fonts alone will not fix every layout problem.

### The final export is unreadable or fails

Check available disk space and try reducing render concurrency. Inspect the actual output with a media player and `ffprobe`. A successful TypeScript check confirms types, not that the browser captured every frame correctly.

## Where to go after this tutorial

You now have a concrete composition you can scrub, modify, and render. Try replacing the access comparison with a diagram from your own article while keeping the same timing and shared animation helpers.

For a next project, the [Remotion Player](https://www.remotion.dev/docs/player) lets you embed a composition as an interactive preview in a React application. The [Node rendering API](https://www.remotion.dev/docs/renderer) and [Lambda documentation](https://www.remotion.dev/docs/lambda) cover automated exports when local rendering is no longer enough. Check the [current licensing terms](https://www.remotion.dev/docs/license) when planning how to use it in your organization.

Build one clear scene, inspect its frames, and then extend the timeline. The same React composition skills carry through as the video grows.