Skip to main content

Displaying captions

This guide explains how to display captions in Remotion, assuming you already have captions in the Caption format - see Transcribing audio for how to generate them.

Fetching captions

First, fetch your captions JSON file. Use useDelayRender() to hold the render until the captions are loaded:

Fetching captions
tsx
import {useState, useEffect, useCallback} from 'react';
import {AbsoluteFill, staticFile, useDelayRender} from 'remotion';
import type {Caption} from '@remotion/captions';
 
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const {delayRender, continueRender, cancelRender} = useDelayRender();
const [handle] = useState(() => delayRender());
 
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile('captions.json'));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
 
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
 
if (!captions) {
return null;
}
 
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
};

Creating pages

Use createTikTokStyleCaptions() to group captions into pages. The combineTokensWithinMilliseconds option controls how many words appear at once:

Creating caption pages
tsx
const {pages} = useMemo(() => {
return createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);

Rendering with Sequences

Map over the pages and render each one in a <Sequence>. Calculate the start frame and duration from the page timing:

Rendering caption pages
tsx
const CaptionedContent: React.FC = () => {
const {fps} = useVideoConfig();
 
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(nextPage ? (nextPage.startMs / 1000) * fps : Infinity, startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps);
const durationInFrames = endFrame - startFrame;
 
if (durationInFrames <= 0) {
return null;
}
 
return (
<Sequence key={index} from={startFrame} durationInFrames={durationInFrames}>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};

Rendering a caption page

A caption page contains tokens which you can use to highlight the currently spoken word. Here's an example that highlights words as they are spoken:

Rendering a caption page with word highlighting
tsx
import {AbsoluteFill, useCurrentFrame, useVideoConfig} from 'remotion';
import type {TikTokPage} from '@remotion/captions';
 
const HIGHLIGHT_COLOR = '#39E508';
 
const CaptionPage: React.FC<{page: TikTokPage}> = ({page}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
 
// Current time relative to the start of the sequence
const currentTimeMs = (frame / fps) * 1000;
// Convert to absolute time by adding the page start
const absoluteTimeMs = page.startMs + currentTimeMs;
 
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
fontSize: 80,
fontWeight: 'bold',
textAlign: 'center',
// Preserve whitespace in captions
whiteSpace: 'pre',
}}
>
{page.tokens.map((token) => {
const isActive = token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
 
return (
<span
key={token.fromMs}
style={{
color: isActive ? HIGHLIGHT_COLOR : 'white',
}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};

Full example

Show full example
Full captioned video example
tsx
import {useState, useEffect, useCallback, useMemo} from 'react';
import {AbsoluteFill, Sequence, staticFile, useCurrentFrame, useDelayRender, useVideoConfig} from 'remotion';
import {createTikTokStyleCaptions} from '@remotion/captions';
import type {Caption, TikTokPage} from '@remotion/captions';
 
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const HIGHLIGHT_COLOR = '#39E508';
 
const CaptionPage: React.FC<{page: TikTokPage}> = ({page}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const currentTimeMs = (frame / fps) * 1000;
const absoluteTimeMs = page.startMs + currentTimeMs;
 
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
fontSize: 80,
fontWeight: 'bold',
textAlign: 'center',
whiteSpace: 'pre',
}}
>
{page.tokens.map((token) => {
const isActive = token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
 
return (
<span
key={token.fromMs}
style={{
color: isActive ? HIGHLIGHT_COLOR : 'white',
}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
 
export const CaptionedVideo: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const {delayRender, continueRender, cancelRender} = useDelayRender();
const [handle] = useState(() => delayRender());
const {fps} = useVideoConfig();
 
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile('captions.json'));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
 
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
 
const {pages} = useMemo(() => {
return createTikTokStyleCaptions({
captions: captions ?? [],
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);
 
return (
<AbsoluteFill style={{backgroundColor: 'black'}}>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(nextPage ? (nextPage.startMs / 1000) * fps : Infinity, startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps);
const durationInFrames = endFrame - startFrame;
 
if (durationInFrames <= 0) {
return null;
}
 
return (
<Sequence key={index} from={startFrame} durationInFrames={durationInFrames}>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};

Next steps

You can customize the appearance of your captions:

  • Use fitText() from @remotion/layout-utils to automatically scale text to fit the video width
  • Add animations for enter/exit effects
  • Apply CSS text stroke for better visibility:
tsx
<div
style={{
WebkitTextStroke: '4px black',
paintOrder: 'stroke',
}}
>
{text}
</div>

See also