R RenderComp
remotion typography subtitles cjk react

CJK Subtitle Line-Breaking in Remotion: Kinsoku and Canvas Layout

By RenderComp Team Editorial policy

Remotion’s core promise is determinism: every frame is a pure function of time. That guarantee holds perfectly for Latin-script video. A subtitle component at frame 42 renders identically on your laptop, in CI, and in a cloud render farm. The moment you add Japanese, Chinese, or Korean text, a quiet assumption breaks. CSS and the browser’s line-break algorithm share control of where text wraps, and where the wrapping happens changes the visual shape of your subtitle on every line. If you are also animating characters appearing one at a time, line positions shift as content accumulates, turning a supposedly deterministic frame-function into something that depends on how many characters have been revealed so far.

The root cause is architectural: CJK scripts have no word-delimiters. Latin text uses spaces as unambiguous break opportunities; the browser’s line-break algorithm can therefore leave glyph-measurement to the layout engine and break only at spaces. CJK runs give the browser nothing but character boundaries, so the engine falls back to a combination of Unicode Line Breaking Algorithm (UAX #14) character classes and browser-specific heuristics. Those heuristics are correct for body text in a document, but they do not compose cleanly with Remotion’s frame-index-driven rendering, especially for character-by-character subtitle reveals where the measured text changes every frame.

The fix is to stop delegating line breaking to CSS entirely. By pre-computing break points in TypeScript before the component mounts (using a canvas element for pixel-accurate measurement and explicit kinsoku rules for Japanese typographic correctness), you regain full control over where every line ends, regardless of frame index or animation state.

What CSS Gets Right, and Where It Falls Apart

CSS exposes several properties that affect CJK line breaking:

  • word-break: normal is the default. CJK characters are breakable at any boundary, while non-CJK text breaks only at spaces.
  • word-break: break-all breaks at any character for every script, which snaps Latin words mid-syllable.
  • word-break: keep-all treats CJK like non-CJK, preventing breaks within CJK runs. Useful for Korean (which has spaces) but disastrous for Japanese body text.
  • line-break: strict enables stricter kinsoku (禁則) processing in Chromium, preventing small kana from appearing at the start of a line, among other constraints.
  • overflow-wrap: anywhere allows breaks regardless of other rules when the line would overflow.

For a static subtitle rendered at full opacity, word-break: normal combined with line-break: strict is usually visually fine. The problem surfaces the moment you animate. Consider a subtitle reveal that exposes one character per frame:

// Naive animated subtitle — layout shifts at every line-wrap threshold
const NaiveReveal: React.FC<{ text: string }> = ({ text }) => {
  const frame = useCurrentFrame();
  const visible = text.slice(0, frame);   // truncated string grows each frame

  return (
    <div style={{
      width: 800,
      wordBreak: 'normal',
      lineBreak: 'strict',
      fontSize: 36,
      fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", sans-serif',
    }}>
      {visible}
    </div>
  );
};

At frame 18, visible might be "映像制作の現場では" and the CSS wraps it to one line. At frame 19, the character "、" (a comma-like pause mark) is appended. Under line-break: strict, a comma cannot start a line, so the engine may shuffle the previous line’s last few characters to the next line to place the comma correctly. Existing characters then jump position at frame 19, which reads on screen as a glitch, not an animation.

Kinsoku: The Rule Set CSS Does Not Expose to JavaScript

Kinsoku shori (禁則処理) is the Japanese typographic standard governing which characters may appear at the start or end of a line. The Unicode standard encodes this in Line Breaking Algorithm character classes, but there is no JavaScript API to query those classes directly. You must implement the rules yourself.

The two critical sets for Japanese video subtitles:

// Characters that cannot begin a line
const LINE_START_PROHIBITED = new Set(
  // Closing punctuation and marks
  '。、.,・:;?!' +
  // Closing brackets and quotes
  ')」』】〕〉》]}' +
  // Long vowel mark and small kana (the most visually obvious violations)
  'ーァィゥェォッャュョヮヵヶ' +
  'ぁぃぅぇぉっゃゅょゎゕゖ' +
  // Iteration marks
  'ゝゞヽヾ々〻'
);

// Characters that cannot end a line
const LINE_END_PROHIBITED = new Set(
  '(「『【〔〈《[{'
);

When your break algorithm considers cutting a line at position i, it must verify that text[i] (the first character on the new line) is not in LINE_START_PROHIBITED, and that text[i-1] (the last character on the current line) is not in LINE_END_PROHIBITED. If either constraint is violated, walk the break point backward until a valid position is found.

Measuring Glyphs with Canvas

The browser’s CanvasRenderingContext2D.measureText() method returns a TextMetrics object whose .width property reflects actual rendered glyph advance width, accounting for the specific font, size, and letter-spacing you specify. This is the same measurement engine the layout renderer uses, so canvas measurements and CSS layout agree, as long as the font is loaded before you measure.

Isolate the measurement into a single function that accepts a pre-configured canvas context:

function buildMeasurer(fontSpec: string): (char: string) => number {
  // Create a single offscreen canvas, reused across all measurement calls.
  // In Remotion's headless Chromium context, document is always available.
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d')!;
  ctx.font = fontSpec;
  // If your design uses letter-spacing, set it here too.
  // ctx.letterSpacing = '0.05em';  // requires Chrome 99+ / Chromium 99+
  return (char: string) => ctx.measureText(char).width;
}

The fontSpec string must exactly match what the browser uses for layout, for example "bold 36px 'Noto Sans JP', 'Yu Gothic', sans-serif", so that canvas measurement and CSS layout see the same advance widths. Any mismatch between the canvas font spec and the component’s font-family will cause the pre-computed breaks to drift from the rendered output.

A Practical breakCJKText Function

With a measurer and kinsoku sets in hand, the algorithm is a single forward scan:

function breakCJKText(
  text: string,
  maxWidthPx: number,
  measure: (char: string) => number,
): string[] {
  const lines: string[] = [];
  let lineStart = 0;
  let lineWidth = 0;

  for (let i = 0; i < text.length; i++) {
    lineWidth += measure(text[i]);

    if (lineWidth <= maxWidthPx) continue;

    // Line is full. Find the rightmost valid break point.
    let cut = i;

    // Walk left past start-prohibited characters (e.g. 。 can't open a line)
    while (cut > lineStart + 1 && LINE_START_PROHIBITED.has(text[cut])) {
      cut--;
    }

    // Walk left past end-prohibited characters (e.g. 「 can't close a line)
    while (cut > lineStart + 1 && LINE_END_PROHIBITED.has(text[cut - 1])) {
      cut--;
    }

    // If no valid break found (rare: e.g. a run of prohibited chars longer
    // than the container), force-break at current position.
    if (cut === lineStart) cut = i;

    lines.push(text.slice(lineStart, cut));
    lineStart = cut;

    // Recompute accumulated width for the carried-over fragment.
    lineWidth = 0;
    for (let j = lineStart; j <= i; j++) {
      lineWidth += measure(text[j]);
    }
  }

  if (lineStart < text.length) {
    lines.push(text.slice(lineStart));
  }

  return lines;
}

The inner recompute loop runs at most maxWidthPx / avgCharWidth iterations. For 800px wide subtitles at 36px, that is roughly 22 characters at most per recompute, so this stays well within render-budget even at 60 fps.

Wiring Into a Remotion Subtitle Component

Pre-compute lines outside the per-frame render path using useMemo. The dependencies are the text, container width, and font spec. None of these change between frames for a given Sequence, so the canvas measurement runs exactly once per cue.

import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from 'remotion';
import { useMemo } from 'react';

const FONT_SIZE = 36;
const FONT_SPEC = `bold ${FONT_SIZE}px 'Noto Sans JP', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', sans-serif`;
const H_PADDING = 80;

type Cue = { from: number; durationInFrames: number; text: string };

export const CJKSubtitleTrack: React.FC<{ cues: Cue[] }> = ({ cues }) => {
  const { width } = useVideoConfig();
  const maxWidth = width - H_PADDING * 2;

  // One measurer for all cues; canvas is created once.
  const measure = useMemo(() => buildMeasurer(FONT_SPEC), []);

  return (
    <AbsoluteFill style={{ justifyContent: 'flex-end', paddingBottom: 60 }}>
      {cues.map((cue, i) => {
        // Lines pre-computed: stable across all frames in this Sequence.
        const lines = breakCJKText(cue.text, maxWidth, measure);
        return (
          <Sequence key={i} from={cue.from} durationInFrames={cue.durationInFrames}>
            <CJKSubtitle lines={lines} />
          </Sequence>
        );
      })}
    </AbsoluteFill>
  );
};

const CJKSubtitle: React.FC<{ lines: string[] }> = ({ lines }) => {
  const frame = useCurrentFrame();
  // Reveal at 1.5 characters per frame (45 chars/sec at 30fps).
  // Adjust the multiplier to match your desired reading pace.
  const charsVisible = Math.floor(frame * 1.5);

  let charsAccumulated = 0;

  return (
    <div style={{
      display: 'inline-block',
      background: 'rgba(0, 0, 0, 0.72)',
      color: '#ffffff',
      fontSize: FONT_SIZE,
      lineHeight: 1.65,
      fontFamily: "'Noto Sans JP', 'Yu Gothic', 'Hiragino Kaku Gothic ProN', sans-serif",
      padding: '10px 20px',
      borderRadius: 6,
      textAlign: 'center',
    }}>
      {lines.map((line, li) => {
        const chars = [...line]; // spread for correct Unicode codepoint iteration
        const row = chars.map((char, ci) => {
          const isVisible = charsAccumulated + ci < charsVisible;
          return (
            <span key={ci} style={{ opacity: isVisible ? 1 : 0 }}>
              {char}
            </span>
          );
        });
        charsAccumulated += chars.length;
        return <div key={li}>{row}</div>;
      })}
    </div>
  );
};

Because lines is computed before the component renders any frame, the <div> elements for each line exist in the DOM from frame 0. Individual character spans toggle opacity rather than toggling presence in the DOM tree, which keeps layout completely stable across all frames.

Handling Mixed CJK and Latin Text

Subtitles that mix Japanese and English, such as "AIによるrenderer最適化", introduce a second constraint: Latin words should not break mid-syllable. Extend the break algorithm with a character-class check:

function isCJK(char: string): boolean {
  const cp = char.codePointAt(0)!;
  return (
    (cp >= 0x4e00 && cp <= 0x9fff) ||    // CJK Unified Ideographs
    (cp >= 0x3040 && cp <= 0x309f) ||    // Hiragana
    (cp >= 0x30a0 && cp <= 0x30ff) ||    // Katakana
    (cp >= 0xac00 && cp <= 0xd7af) ||    // Hangul Syllables
    (cp >= 0x3400 && cp <= 0x4dbf)       // CJK Extension A
  );
}

function isBreakableAt(text: string, index: number): boolean {
  // A CJK character boundary is always a valid break candidate.
  if (isCJK(text[index])) return true;
  // A space is always breakable.
  if (text[index] === ' ') return true;
  // Mid-Latin: only break if the preceding character is also non-Latin
  // (i.e., we are at a script boundary, not inside a word).
  const prev = text[index - 1] ?? '';
  return isCJK(prev) || prev === ' ';
}

Replace the forward-scan line-full check in breakCJKText with if (lineWidth > maxWidthPx && isBreakableAt(text, i)) to skip break candidates that fall inside Latin words. If no breakable position is found before overflow (a Latin word longer than the container), fall back to force-breaking at the overflow point, which matches the behavior overflow-wrap: anywhere would give you.

Font Loading Before the First Measurement

Canvas measurement is meaningless if the font has not loaded: measureText falls back to a system font and the widths are wrong. Remotion’s delayRender / continueRender API pauses the render pipeline until you explicitly release the hold.

// fonts.ts — call once from your root composition
import { delayRender, continueRender } from 'remotion';

export function waitForFont(family: string): void {
  const handle = delayRender(`Waiting for font: ${family}`);
  document.fonts.ready.then(() => {
    continueRender(handle);
  });
}

Declare the font with @font-face pointing to a file in your public/ directory (no external CDN):

/* src/styles/fonts.css — imported once in your Root component */
@font-face {
  font-family: 'Noto Sans JP';
  src: url('/fonts/NotoSansJP-Bold.woff2') format('woff2');
  font-weight: 700;
  /* block: prevent FOUT during the render pass */
  font-display: block;
}

Call waitForFont('Noto Sans JP') at the top level of your root composition, not inside any Sequence. With font-display: block, Chromium will not paint any text until the woff2 is available, and delayRender ensures the render pipeline does not capture that blank frame.

Wrapping Up

The key takeaways for production CJK subtitle work in Remotion:

  1. Delegate nothing to CSS for animated subtitles. word-break and line-break are correct for static layout but introduce frame-dependent shifts when text is being revealed incrementally.

  2. Canvas measureText is the right measurement primitive. It uses the same glyph metrics as the layout engine, it runs synchronously, and it respects the font specification you set on the context. Always match the canvas font spec to the component’s computed style.

  3. Kinsoku rules cover a manageable set of character constraints, with roughly 40 start-prohibited and 8 end-prohibited characters for Japanese. Skipping them makes subtitles look amateurish to any native reader, so implement them as a two-pass walkback at each break point.

  4. For mixed CJK + Latin text, add a script-boundary detection layer that prevents breaks inside ASCII word runs, then fall back to force-break only on genuine overflow.

  5. Font must load before measurement. Use delayRender / continueRender with document.fonts.ready, and font-display: block to guarantee the woff2 is available before any frame is captured.

These patterns scale directly to multi-language productions, including Korean, Traditional Chinese, and Simplified Chinese, since the same isCJK ranges and kinsoku sets cover the common prohibited characters across all three scripts. Templates in the RenderComp catalog that ship CJK subtitle tracks follow this architecture, keeping line positions stable whether you render locally or distribute across a remote render cluster.

Now available

Get 1,000+ Remotion Templates

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

View pricing →