Catch Text Overflow Before a Translated Remotion Template Ships
By RenderComp Team Editorial policy
Remotion renders each video frame as a deterministic function of time. Feed it frame 47 and width 1920, and it produces exactly the same pixel output every time: no layout engine negotiation, no adaptive reflow, no viewport quirks. That determinism is what makes programmatic video powerful. It is also what makes translated copy dangerous.
In a web browser, if your German headline runs 35% longer than the English original, overflow: hidden silently clips it, or text-wrap: balance finds a workable line break, and most visitors never notice. In a Remotion frame, what renders is the final truth. There is no secondary layout pass. A headline that overflows its container in frame 1 overflows in every rendered frame 1, forever, in the delivered MP4.
Translators do not write video layout constraints. A localization CSV that arrives Monday and ships Friday will contain strings tested in a word processor, not against your 640×200 lower-third composition. This article walks through four techniques to catch overflow before it reaches a rendered file.
Why Character Count Is a Necessary but Insufficient Guard
The first instinct is to cap string length. That is correct but incomplete. English-to-German expansion averages 20–35% by character count. English-to-Finnish can reach 40–60% for compound nouns. English-to-Japanese usually contracts in character count but expands in rendered width per glyph because CJK characters are full-width; a 20-character Japanese title may be wider than a 32-character English equivalent at the same font-size.
Beyond raw length, the interaction between font-size, letter-spacing, font-weight, and line-height means two strings with identical character counts can produce dramatically different layout widths. A 48px semi-bold B is 32px wide in one grotesque and 36px wide in a condensed variant. If your composition loads a self-hosted custom typeface, you cannot reliably estimate pixel width from character count alone.
What you need is a layered defense: fail-fast character budgets in calculateMetadata, canvas-based pixel measurement in the Studio, a visual overflow guard during composition authoring, and a locale sweep composition for final review. Each catches a different class of breakage at a different point in the workflow.
Character Budget Checks in calculateMetadata
Remotion’s calculateMetadata function runs before any frame is captured. If it throws, npx remotion render exits with a non-zero code, the Studio shows an inline error, and no pixels are rendered. That makes it the right place for hard limits.
Define per-locale character budgets derived from your layout constraints and expected expansion ratios:
// src/compositions/LowerThird/calculateMetadata.ts
import { CalculateMetadataFunction } from 'remotion';
import { z } from 'zod';
const schema = z.object({
name: z.string(),
title: z.string(),
locale: z.enum(['en', 'de', 'fr', 'fi', 'ja']),
});
export type LowerThirdProps = z.infer<typeof schema>;
// Budget in characters. Derived from: layout max-width / average glyph width per locale.
// A 40-char English name field measured against your specific font and font-size:
const NAME_CHAR_BUDGET: Record<LowerThirdProps['locale'], number> = {
en: 40,
de: 30, // German expands ~30% on average
fr: 32, // French expands ~20-25%
fi: 24, // Finnish compound nouns expand up to 60%
ja: 18, // CJK glyphs are full-width; 18 chars ≈ 40 half-width chars at most weights
};
const TITLE_CHAR_BUDGET: Record<LowerThirdProps['locale'], number> = {
en: 55,
de: 42,
fr: 46,
fi: 34,
ja: 24,
};
export const calculateMetadata: CalculateMetadataFunction<LowerThirdProps> = async ({
props,
}) => {
const nameBudget = NAME_CHAR_BUDGET[props.locale];
const titleBudget = TITLE_CHAR_BUDGET[props.locale];
if (props.name.length > nameBudget) {
throw new Error(
`[${props.locale}] name "${props.name}" exceeds ${nameBudget}-char budget ` +
`(${props.name.length} chars). Shorten or split across two lines.`
);
}
if (props.title.length > titleBudget) {
throw new Error(
`[${props.locale}] title exceeds ${titleBudget}-char budget ` +
`(${props.title.length} chars).`
);
}
// Returning only `props` leaves durationInFrames / fps / dimensions
// at the values registered in <Composition />.
return { props };
};
Register the function on the composition:
// src/Root.tsx
import { Composition } from 'remotion';
import { LowerThird } from './compositions/LowerThird/LowerThird';
import { calculateMetadata } from './compositions/LowerThird/calculateMetadata';
export const RemotionRoot: React.FC = () => (
<>
<Composition
id="LowerThird"
component={LowerThird}
fps={30}
durationInFrames={90}
width={1920}
height={1080}
calculateMetadata={calculateMetadata}
defaultProps={{
name: 'Alex Martin',
title: 'Head of Product Development',
locale: 'en',
}}
/>
</>
);
When your CI pipeline calls npx remotion render LowerThird --props='{"name":"Müller-Großmann","title":"Leiterin der Produktentwicklung","locale":"de"}', the character guard fires before a single frame renders. Exit code 1 fails the pipeline step, and no manual review is needed for the straightforward cases.
Character budgets are conservative approximations, however. A 28-character German compound in a wide grotesque weight can still pixel-overflow a 30-char budget slot, which is where canvas-based pixel measurement becomes necessary.
Canvas Pixel Measurement in the Studio
The Canvas 2D API’s measureText() returns actual advance-width metrics using the same text rendering engine as the browser’s layout. In the Remotion Studio (which runs in Chrome), the Canvas context shares the browser’s font cache, including any custom typefaces loaded via @font-face.
Write a utility that mirrors the exact CSS font declaration used in your composition:
// src/utils/measureText.ts
/**
* Returns the advance width of `text` in pixels using the Canvas 2D API.
* `fontSpec` must be a valid CSS font shorthand, e.g. "600 52px 'YourFont', sans-serif".
* Only reliable in a browser context (Studio). In Node.js (CLI render), OffscreenCanvas
* is unavailable — fall back to the character-budget guard in calculateMetadata.
*/
export function measureTextWidth(text: string, fontSpec: string): number {
// OffscreenCanvas avoids touching the visible DOM and has no size constraints.
const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
ctx.font = fontSpec;
return ctx.measureText(text).width;
}
export function wouldOverflow(
text: string,
fontSpec: string,
maxWidthPx: number
): boolean {
return measureTextWidth(text, fontSpec) > maxWidthPx;
}
Add pixel checks to calculateMetadata, guarded by typeof OffscreenCanvas !== 'undefined' so they silently skip when Remotion’s CLI orchestrator runs the function in Node.js:
// src/compositions/LowerThird/calculateMetadata.ts — additions
import { measureTextWidth, wouldOverflow } from '../../utils/measureText';
export const calculateMetadata: CalculateMetadataFunction<LowerThirdProps> = async ({
props,
}) => {
// Character budget check — runs in Node and browser (see Technique 1)
// ...
// Pixel check — browser (Studio) only
if (typeof OffscreenCanvas !== 'undefined') {
// Wait for all @font-face sources to finish loading before measuring.
// Without this, Canvas may fall back to a system font on cold Studio starts.
await document.fonts.ready;
// Font spec must exactly match the CSS declaration in LowerThird.tsx.
const nameFontSpec = '600 52px "InterDisplay", "Segoe UI", sans-serif';
// Layout budget: 1920px canvas minus 80px left padding and 80px right margin.
const nameMaxPx = 1920 - 160;
if (wouldOverflow(props.name, nameFontSpec, nameMaxPx)) {
throw new Error(
`[${props.locale}] name pixel-overflows the ${nameMaxPx}px layout budget. ` +
`Measured: ~${Math.round(measureTextWidth(props.name, nameFontSpec))}px`
);
}
}
return { props };
};
The await document.fonts.ready line is load-bearing. FontFaceSet.ready is a Promise that resolves once every declared @font-face source has finished loading. If you measure before the custom typeface is available, Canvas silently substitutes a generic font and returns incorrect widths. Those widths may be narrower than the real render, letting overflow slip through undetected.
The Overflow Guard Component
Character budgets and canvas checks validate input props. A visual signal during composition authoring is more ergonomic: a component that draws a red outline over any child content that overflows its container, visible only in the Studio.
The key mechanism is delayRender/continueRender. Without these, Puppeteer may capture the frame before useEffect has fired and the component has re-rendered with the outline. The pair tells Remotion to hold the current frame until the measurement is complete.
// src/components/TextOverflowGuard.tsx
import React, { useEffect, useRef, useState } from 'react';
import { continueRender, delayRender } from 'remotion';
interface TextOverflowGuardProps {
children: React.ReactNode;
style?: React.CSSProperties;
}
/**
* Wraps children in a fixed-size container. In development, shows a 2px red
* outline if the content overflows. No visual effect or render delay in production.
*/
export const TextOverflowGuard: React.FC<TextOverflowGuardProps> = ({
children,
style,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
// Create the handle synchronously on mount. In production, skip it entirely.
const [handle] = useState<number | null>(() =>
process.env.NODE_ENV === 'development'
? delayRender('TextOverflowGuard: measuring overflow')
: null
);
useEffect(() => {
if (containerRef.current) {
// scrollWidth exceeds clientWidth when text content is wider than the box.
// scrollHeight exceeds clientHeight when content is taller (multi-line overflow).
const widthOverflow =
containerRef.current.scrollWidth > containerRef.current.clientWidth;
const heightOverflow =
containerRef.current.scrollHeight > containerRef.current.clientHeight;
if (widthOverflow || heightOverflow) {
setOverflows(true);
console.warn(
'[TextOverflowGuard] Overflow detected.',
`scrollWidth=${containerRef.current.scrollWidth}`,
`clientWidth=${containerRef.current.clientWidth}`,
`scrollHeight=${containerRef.current.scrollHeight}`,
`clientHeight=${containerRef.current.clientHeight}`
);
}
}
// Release the render hold regardless of outcome.
if (handle !== null) continueRender(handle);
}, [handle]);
const outlineStyle: React.CSSProperties =
overflows && process.env.NODE_ENV === 'development'
? { outline: '2px solid red', outlineOffset: '2px' }
: {};
return (
<div
ref={containerRef}
style={{
...style,
// overflow:hidden must override whatever the caller passes,
// because scrollWidth > clientWidth only registers when the box clips content.
overflow: 'hidden',
...outlineStyle,
}}
>
{children}
</div>
);
};
Use it in your composition around any text that has a fixed layout constraint:
// src/compositions/LowerThird/LowerThird.tsx
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from 'remotion';
import { TextOverflowGuard } from '../../components/TextOverflowGuard';
import type { LowerThirdProps } from './calculateMetadata';
export const LowerThird: React.FC<LowerThirdProps> = ({ name, title }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slightly underdamped spring: stiffness 120 / damping 14 produces a quick
// entry with a subtle 4-5% overshoot before settling — readable by frame 20.
const slideX = spring({
frame,
fps,
config: { stiffness: 120, damping: 14, mass: 1 },
from: -400,
to: 0,
});
return (
<AbsoluteFill>
<div
style={{
position: 'absolute',
bottom: 160,
left: 80,
transform: `translateX(${slideX}px)`,
}}
>
{/* maxWidth = canvas 1920px − left 80px − right margin 80px */}
<TextOverflowGuard style={{ maxWidth: 1760 }}>
<p
style={{
fontFamily: '"InterDisplay", "Segoe UI", sans-serif',
fontSize: 52,
fontWeight: 600,
color: '#fff',
margin: 0,
whiteSpace: 'nowrap',
}}
>
{name}
</p>
</TextOverflowGuard>
<TextOverflowGuard style={{ maxWidth: 1760, marginTop: 8 }}>
<p
style={{
fontFamily: '"InterDisplay", "Segoe UI", sans-serif',
fontSize: 36,
fontWeight: 400,
color: 'rgba(255,255,255,0.75)',
margin: 0,
whiteSpace: 'nowrap',
}}
>
{title}
</p>
</TextOverflowGuard>
</div>
</AbsoluteFill>
);
};
In production builds (NODE_ENV !== 'development'), no handle is created and no render delay is introduced. The guard component becomes a transparent pass-through with zero cost to the shipped video.
The Locale Sweep Composition
The previous techniques guard individual strings at prop-validation time. A locale sweep composition lets you review all locales in a single render or Studio scrub, with the TextOverflowGuard overlay active, before the template ships.
Register it as a dedicated Composition used only for pre-ship review:
// src/compositions/LocaleSweep.tsx
import React from 'react';
import { AbsoluteFill, Sequence } from 'remotion';
import { LowerThird } from './LowerThird/LowerThird';
import type { LowerThirdProps } from './LowerThird/calculateMetadata';
const LOCALES: Array<LowerThirdProps['locale']> = ['en', 'de', 'fr', 'fi', 'ja'];
// Representative worst-case strings per locale — use the longest variants from
// the actual translation file, not averaged examples.
const TRANSLATIONS: Record<
LowerThirdProps['locale'],
Pick<LowerThirdProps, 'name' | 'title'>
> = {
en: { name: 'Alexandra Thompson', title: 'Head of Product Development' },
de: { name: 'Alexandra Thompson', title: 'Leiterin der Produktentwicklung' },
fr: { name: 'Alexandra Thompson', title: 'Responsable du développement produit' },
fi: { name: 'Alexandra Thompson', title: 'Tuotekehityspäällikkö' },
ja: { name: 'Alexandra Thompson', title: '製品開発責任者' },
};
// 3 seconds at 30 fps per locale gives enough time to read the layout clearly.
const FRAMES_PER_LOCALE = 90;
export const LocaleSweep: React.FC = () => (
<AbsoluteFill style={{ backgroundColor: '#0f0f0f' }}>
{LOCALES.map((locale, index) => (
<Sequence
key={locale}
from={index * FRAMES_PER_LOCALE}
durationInFrames={FRAMES_PER_LOCALE}
>
{/* Locale label — visible while scrubbing in the Studio */}
<div
style={{
position: 'absolute',
top: 24,
left: 80,
fontFamily: '"Segoe UI", "Yu Gothic", sans-serif',
fontSize: 24,
fontWeight: 500,
color: 'rgba(255,255,255,0.35)',
}}
>
{locale.toUpperCase()}
</div>
<LowerThird
locale={locale}
name={TRANSLATIONS[locale].name}
title={TRANSLATIONS[locale].title}
/>
</Sequence>
))}
</AbsoluteFill>
);
Register the sweep in Root.tsx:
// src/Root.tsx — add alongside your production compositions
<Composition
id="LocaleSweep"
component={LocaleSweep}
fps={30}
durationInFrames={LOCALES.length * 90} // 5 × 90 = 450 frames = 15 seconds
width={1920}
height={1080}
/>
Run NODE_ENV=development npx remotion render LocaleSweep --output out/locale-sweep.mp4 as part of a pre-ship review. Because TextOverflowGuard checks NODE_ENV, any overflowing locale renders with the red outline burned into the output video. The resulting 15-second file shows every translated variant in context; scrub through it before routing to the production render queue.
Use the longest strings from your actual translation file, not averaged examples. The sweep is only as useful as the strings you put into it. If a localization CSV drops late, re-run the sweep before triggering the full render batch.
Wrapping Up
Overflow in translated video templates is a predictable failure mode. It emerges from the mismatch between localization workflows and fixed-canvas rendering. These four techniques address it at different points in the pipeline:
| Technique | When it runs | What it catches |
|---|---|---|
calculateMetadata character budget | Before any frame renders, in Node and browser | Strings that exceed per-locale safe character limits |
| Canvas pixel measurement | In the Studio (browser context only) | Strings that pass the character budget but pixel-overflow the layout |
TextOverflowGuard component | During authoring and Studio preview | Any content overflow, with a visual overlay and console warning |
| Locale sweep composition | Pre-ship review render | All locales in one video, with overflow guards active |
Stack all four. The character budget is your CI gate, cheap and zero-dependency, running everywhere. The canvas check catches the cases your budget approximates poorly, particularly for variable-weight fonts and CJK content. The guard component closes the feedback loop during authoring so you catch overflow the moment you paste a long string into the Studio. The sweep composition provides the final sign-off before any locale hits the production render queue.
Templates structured with these checks at the composition level (like the ones in the RenderComp catalog) make translated variants a configuration problem rather than a QC problem. The layout either passes all four gates or it does not render.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →