R RenderComp
remotion localization typescript i18n react

Four Remotion Bugs You Only Find in a Second Language

By RenderComp Team Editorial policy

Remotion’s core design contract is determinism: frame N always produces the same pixels, across every machine and every render pass. That property is what makes programmatic video generation reliable, but it also makes localization bugs impossible to paper over. A React web app survives a CJK font loading 200ms late because the user just sees a flash of fallback glyphs. Remotion renders frame 0 before your font is available, bakes that fallback glyph into the H.264 stream, and ships it.

This article is a forensic autopsy of one template (a product-launch announcement card) as it was adapted for English, Japanese, German, and Arabic. Each locale revealed a distinct failure class, and none of them showed up in the browser preview before the first render. Understanding why each one happens is more useful than memorizing a patch list, so this article works through the mechanism behind every bug.

The template: a 5-second (150-frame) clip at 30 fps. A headline slides in from the left, a subheadline fades up, and a call-to-action button springs open to fit its label. The original target was English. Everything worked fine until the client asked for three more locales.


The Base Template

Here is the full composition before any i18n work. Read it carefully: each of the four failures is latent in this code.

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

type Props = {
  headline: string;
  sub: string;
  cta: string;
};

export const ProductLaunch: React.FC<Props> = ({ headline, sub, cta }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Headline slides in from -60px over the first 20 frames (0.67 s at 30 fps)
  const headlineX = interpolate(frame, [0, 20], [-60, 0], {
    extrapolateRight: 'clamp',
  });

  // Subheadline fades in after the headline settles
  const subOpacity = interpolate(frame, [20, 35], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // CTA button width springs open at frame 40; 180 fits "Buy Now" in Inter 16px
  const buttonWidth = spring({
    frame: frame - 40,
    fps,
    config: { stiffness: 120, damping: 14 },
    from: 0,
    to: 180,
  });

  return (
    <AbsoluteFill style={{ background: '#0f0f0f', justifyContent: 'center', padding: 48 }}>
      <h1 style={{ transform: `translateX(${headlineX}px)`, color: '#fff', fontSize: 48 }}>
        {headline}
      </h1>
      <p style={{ opacity: subOpacity, color: '#aaa', fontSize: 20, marginTop: 16 }}>
        {sub}
      </p>
      <button
        style={{
          marginTop: 32,
          width: buttonWidth,
          overflow: 'hidden',
          whiteSpace: 'nowrap',
          background: '#6c47ff',
          color: '#fff',
          fontSize: 16,
          padding: '12px 24px',
          borderRadius: 8,
          border: 'none',
        }}
      >
        {cta}
      </button>
    </AbsoluteFill>
  );
};

The English version shipped without issues. The four failures only emerge when other locales are added.


The Font That Wasn’t There at Frame Zero (Failure 1)

Japanese requires a CJK font. The team dropped a self-hosted Noto Sans JP woff2 into public/fonts/ and added fontFamily: '"Noto Sans JP", sans-serif' to the outer container’s inline styles.

In the browser preview, this looked fine after a second or two. In the render output, the first 12 frames used the OS fallback CJK font. The fallback’s ascent and descent metrics were 8px different from Noto Sans JP’s, so when the real font loaded at frame 13, the headline visually jumped downward mid-slide-in.

The mechanism: Remotion’s renderer is a headless Chromium process. It navigates to each composition URL and begins screenshotting at frame 0. The @font-face rule triggers a network fetch, even for a staticFile() URL, that completes asynchronously. Chromium does not block layout on unloaded fonts by default; it uses a fallback immediately and swaps when ready.

The fix is delayRender and continueRender. Remotion will not capture any frame until every active delayRender handle has been resolved.

// src/hooks/useFont.ts
import { useEffect, useState } from 'react';
import { delayRender, continueRender } from 'remotion';

export function useFont(family: string, src: string): boolean {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const handle = delayRender(`Loading font: ${family}`);

    const face = new FontFace(family, `url(${src})`);
    face
      .load()
      .then((loaded) => {
        document.fonts.add(loaded);
        setReady(true);
        continueRender(handle);
      })
      .catch((err) => {
        // Unblock render on failure so the video isn't stuck indefinitely
        console.error('Font load failed:', err);
        continueRender(handle);
      });
  }, []); // intentionally empty — font identity is stable per composition mount

  return ready;
}
// In the composition
import { staticFile } from 'remotion';
import { useFont } from '../hooks/useFont';

export const ProductLaunch: React.FC<Props & { locale: string }> = ({ locale, ...props }) => {
  const needsCJK = locale === 'ja' || locale === 'zh' || locale === 'ko';
  const fontReady = useFont('Noto Sans JP', staticFile('fonts/NotoSansJP-Regular.woff2'));

  // Return null (renders transparent frame) until font is confirmed loaded.
  // Remotion sees the delayRender handle and waits — it never screenshots this null frame.
  if (needsCJK && !fontReady) return null;

  // ...
};

The staticFile() call resolves relative to your project’s public/ directory and works identically in local dev and on the render worker, so avoid hardcoding raw string paths.


The Typewriter That Doesn’t Know Japanese (Failure 2)

After the font problem was fixed, a typewriter animation was added to the headline, revealing characters one by one. The implementation derived the animation duration from headline.length:

// Duration scales with character count — this will betray you
const CHARS_PER_SECOND = 8;
const typewriterDuration = Math.ceil((headline.length / CHARS_PER_SECOND) * fps);

const visibleChars = Math.floor(
  interpolate(frame, [0, typewriterDuration], [0, headline.length], {
    extrapolateRight: 'clamp',
  })
);

English headline "Introducing Prism": 18 characters, 67 frames, 2.2 seconds. Fine.

Japanese headline "Prismをご紹介します": 10 characters, 37 frames, 1.2 seconds. The animation completed nearly a second early because Japanese conveys more semantic content per character. The headline finished revealing while the viewer was still parsing it, then hung in empty space waiting for the subheadline’s frame 20 trigger.

German headline "Wir stellen Prism vor": 21 characters, 78 frames. This pushed the button spring’s frame - 40 offset into overlapping territory with the subheadline fade, compressing all three animations into a visually cluttered 1.3-second window.

The correct approach is to fix the animation duration in frames and let the interpolate output range distribute characters across that window, keeping duration a design constant rather than a side effect of translated text length.

// Fixed: 45 frames = 1.5 seconds at 30 fps, regardless of locale or string length
const TYPEWRITER_DURATION = 45;

const visibleChars = Math.round(
  interpolate(frame, [0, TYPEWRITER_DURATION], [0, headline.length], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  })
);

If a locale genuinely needs more reveal time (say, because the translated headline is unusually long), expose it as a prop so locale teams can tune it explicitly, not as an implicit function of character count.

type Props = {
  headline: string;
  typewriterDuration?: number; // default 45; locale teams set per-locale if needed
};

The Button That Swallowed Its Text (Failure 3)

The CTA button spring-animates to a hardcoded target of 180. In English, "Buy Now" at Inter 16px measures at roughly 72px of text, so the 180px target provides comfortable 24px padding on each side. The German CTA "Jetzt kaufen" measures at approximately 96px, still within the 180px target. The French CTA "Commencer l'essai gratuit" measures at around 208px. The spring animated to 180, and overflow: hidden silently clipped the label mid-word.

The fix: measure the actual rendered text width and use it as the spring target. The Canvas 2D API is available synchronously in Remotion’s renderer.

// src/utils/measureText.ts

// Reuse a single canvas instance across frames — instantiation is expensive
let _canvas: HTMLCanvasElement | null = null;

export function measureTextWidth(text: string, cssFont: string): number {
  if (typeof window === 'undefined') return 200; // non-browser environment fallback
  if (!_canvas) _canvas = document.createElement('canvas');
  const ctx = _canvas.getContext('2d')!;
  ctx.font = cssFont;
  return ctx.measureText(text).width;
}

The call is synchronous and takes under 0.1ms. Since cta is stable across frames, the measurement produces the same number every time, so there is no jitter across frames.

One critical subtlety: if the locale uses a font loaded via useFont, you must measure only after fontReady is true. Otherwise you measure with the fallback font’s metrics, and the spring target will be wrong by however much the two fonts differ in character advance widths.

const PADDING_X = 48; // 24px each side
const MIN_BUTTON_WIDTH = 120;

// useMemo must be called before any conditional return — hooks ordering rule
const buttonTarget = useMemo(() => {
  // While CJK font is loading, use a placeholder so the hook isn't skipped
  if (needsCJK && !fontReady) return MIN_BUTTON_WIDTH;
  const measured = measureTextWidth(cta, `600 16px ${fontFamily}`);
  return Math.max(MIN_BUTTON_WIDTH, Math.ceil(measured) + PADDING_X);
}, [cta, fontFamily, fontReady, needsCJK]);

const buttonWidth = spring({
  frame: frame - 40,
  fps,
  config: { stiffness: 120, damping: 14 },
  from: 0,
  to: buttonTarget, // now driven by measured text, not a guess
});

The RTL Animation That Pointed the Wrong Way (Failure 4)

Arabic requires right-to-left layout. Adding dir="rtl" to the outer container made the text render correctly. The headline slide-in, however, still translated from -60px to 0. In RTL, negative X is toward the end of the reading line (the left edge), so the headline appeared to slide in from the wrong side, opposite to where Arabic reading begins.

// Wrong for RTL: entry from negative X = entry from the reading-end edge
const headlineX = interpolate(frame, [0, 20], [-60, 0], {
  extrapolateRight: 'clamp',
});

Arabic and Hebrew readers expect content to enter from the right (the start of the reading line), which means the entry offset should be positive X:

const isRTL = locale === 'ar' || locale === 'he';

// Positive offset = enters from right; negative = enters from left
const entryOffset = isRTL ? 60 : -60;

const headlineX = interpolate(frame, [0, 20], [entryOffset, 0], {
  extrapolateRight: 'clamp',
});

The same inversion applies to transform-origin on any animation anchored to the inline-start edge. A character-level scale reveal using transform-origin: left center will need transform-origin: right center in RTL; otherwise the scale pivot is on the wrong end of the word.

For the button, replacing width with maxWidth and letting the flex container handle alignment produces a spring that expands in the natural inline direction without any additional conditional:

<div style={{ display: 'flex', justifyContent: isRTL ? 'flex-end' : 'flex-start' }}>
  <button
    style={{
      maxWidth: buttonWidth, // grows from inline-start edge in both LTR and RTL
      overflow: 'hidden',
      whiteSpace: 'nowrap',
      // ...
    }}
  >
    {cta}
  </button>
</div>

The Repaired Composition

All four fixes assembled:

// src/compositions/ProductLaunch.tsx (localized)
import React, { useMemo } from 'react';
import {
  AbsoluteFill,
  interpolate,
  spring,
  staticFile,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { useFont } from '../hooks/useFont';
import { measureTextWidth } from '../utils/measureText';

const CJK_LOCALES = new Set(['ja', 'zh', 'ko']);
const RTL_LOCALES = new Set(['ar', 'he']);

const TYPEWRITER_DURATION = 45; // 1.5 s at 30 fps — invariant across locales
const PADDING_X = 48;
const MIN_BUTTON_WIDTH = 120;

type Props = {
  headline: string;
  sub: string;
  cta: string;
  locale: string;
};

export const ProductLaunch: React.FC<Props> = ({ headline, sub, cta, locale }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const needsCJK = CJK_LOCALES.has(locale);
  const isRTL = RTL_LOCALES.has(locale);

  const fontFamily = needsCJK
    ? '"Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif'
    : '-apple-system, "Segoe UI", Roboto, sans-serif';

  // Hooks must all be called before any conditional return
  const fontReady = useFont('Noto Sans JP', staticFile('fonts/NotoSansJP-Regular.woff2'));

  const buttonTarget = useMemo(() => {
    if (needsCJK && !fontReady) return MIN_BUTTON_WIDTH;
    const measured = measureTextWidth(cta, `600 16px ${fontFamily}`);
    return Math.max(MIN_BUTTON_WIDTH, Math.ceil(measured) + PADDING_X);
  }, [cta, fontFamily, fontReady, needsCJK]);

  // delayRender blocks this — Remotion never screenshots the null frame
  if (needsCJK && !fontReady) return null;

  const visibleChars = Math.round(
    interpolate(frame, [0, TYPEWRITER_DURATION], [0, headline.length], {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
    })
  );

  const entryOffset = isRTL ? 60 : -60;
  const headlineX = interpolate(frame, [0, 20], [entryOffset, 0], {
    extrapolateRight: 'clamp',
  });

  const subOpacity = interpolate(frame, [20, 35], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const buttonWidth = spring({
    frame: frame - 40,
    fps,
    config: { stiffness: 120, damping: 14 },
    from: 0,
    to: buttonTarget,
  });

  return (
    <AbsoluteFill
      dir={isRTL ? 'rtl' : 'ltr'}
      style={{ background: '#0f0f0f', justifyContent: 'center', padding: 48, fontFamily }}
    >
      <h1 style={{ transform: `translateX(${headlineX}px)`, color: '#fff', fontSize: 48 }}>
        {headline.slice(0, visibleChars)}
      </h1>
      <p style={{ opacity: subOpacity, color: '#aaa', fontSize: 20, marginTop: 16 }}>
        {sub}
      </p>
      <div style={{ display: 'flex', justifyContent: isRTL ? 'flex-end' : 'flex-start' }}>
        <button
          style={{
            marginTop: 32,
            maxWidth: buttonWidth,
            overflow: 'hidden',
            whiteSpace: 'nowrap',
            background: '#6c47ff',
            color: '#fff',
            fontSize: 16,
            padding: '12px 24px',
            borderRadius: 8,
            border: 'none',
          }}
        >
          {cta}
        </button>
      </div>
    </AbsoluteFill>
  );
};

Wrapping Up

Four failure modes, four rules:

Font loading is a render blocker, not a style detail. Use delayRender/continueRender for every locale-specific font. The browser preview lies because it waits for fonts; the render worker does not unless you tell it to.

Animation duration belongs to the design, not the string. Fix frame counts as named constants. If a locale needs more time, expose a prop rather than deriving it from headline.length. Semantic density varies too much across languages for character count to be a reliable proxy for reading time.

Spring targets must be measured, not guessed. Canvas measureText is synchronous and costs under a tenth of a millisecond per call. Pass its result as your to value and you will never clip a CTA label again, regardless of what the copy team hands you. Measure only after font load is confirmed.

Every directional animation has a mirror in RTL. Slide-in offsets, transform-origin edges, and flex alignment all need to respect the inline direction. A single isRTL flag, applied consistently at the top of your composition, handles this. Test it by adding dir="rtl" to the outer container in your browser preview and verifying that all animations feel natural.

These are the bugs that accumulate silently in production templates: the kind that multi-locale templates in the RenderComp catalog have to handle before they can ship across regions. Remotion’s frame-determinism makes them impossible to hide at runtime, which is ultimately useful. The problem appears at frame 1 of your first render, not in a client complaint three months later.

Now available

Get 1,000+ Remotion Templates

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

View pricing →