Skip to content

Remotion

Tegaki integrates with Remotion for programmatic video rendering. Because Remotion drives time via useCurrentFrame(), the trick is to put Tegaki in controlled progress mode and map frame / durationInFrames to a 0–1 progress value.

npm install tegaki remotion @remotion/cli react react-dom
import { Composition, useCurrentFrame, useVideoConfig } from 'remotion';
import { TegakiRenderer } from 'tegaki';
import caveat from 'tegaki/fonts/caveat';

const Handwriting: React.FC = () => {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();
  const progress = frame / durationInFrames;

  return (
    <div style={{ flex: 1, backgroundColor: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <TegakiRenderer
        font={caveat}
        text="What a wonderful world"
        style={{ fontSize: 120 }}
        time={{ mode: 'controlled', value: progress, unit: 'progress' }}
      />
    </div>
  );
};

export const RemotionRoot: React.FC = () => {
  return (
    <Composition
      id="Handwriting"
      component={Handwriting}
      durationInFrames={180}
      fps={60}
      width={1920}
      height={1080}
    />
  );
};

The unit: 'progress' option interprets value as a 0–1 ratio of the total animation duration, so you don’t need to know Tegaki’s computed timeline length: Remotion’s composition length drives the mapping directly.

Tegaki’s font bundles (tegaki/fonts/caveat, tegaki/fonts/italianno, etc.) load their own font: the engine creates a FontFace and registers it with document.fonts. Remotion waits on document.fonts.ready before capturing each frame, so no loadFont call is required. Pass the bundle to TegakiRenderer.

Rendering with remotion render needs no setup, but Remotion Studio’s dev server serves the TTF URL incorrectly because the bundled path escapes the studio’s root (webpack emits ../../node_modules/tegaki/fonts/caveat/caveat.ttf, which the SPA catch-all intercepts and returns HTML).

The fix is to tell webpack to inline .ttf files as data URLs. Add a remotion.config.ts at the root of your Remotion project:

// remotion.config.ts
import { Config } from '@remotion/cli/config';

Config.overrideWebpackConfig((config) => {
  return {
    ...config,
    module: {
      ...config.module,
      rules: [
        ...(config.module?.rules ?? []).filter((rule) => {
          if (typeof rule !== 'object' || !rule || !rule.test) return true;
          const test = rule.test;
          return !(test instanceof RegExp && test.test('a.ttf'));
        }),
        {
          test: /\.ttf$/,
          type: 'asset/inline',
        },
      ],
    },
  };
});

This works in both the studio and in render/build mode. The bundled font is ~60–250 kB depending on the family, which is negligible for a video bundle.

By default, progress = frame / durationInFrames spreads the handwriting animation across the entire composition. Two common variations:

Finish drawing before the end of the composition, e.g. to hold the finished text for the last second:

const { fps, durationInFrames } = useVideoConfig();
const drawFrames = durationInFrames - fps; // leave 1s of hold
const progress = Math.min(1, frame / drawFrames);

Start drawing after a delay:

const { fps } = useVideoConfig();
const delayFrames = fps; // 1s delay
const drawFrames = 120; // 2s to draw
const progress = Math.max(0, Math.min(1, (frame - delayFrames) / drawFrames));

Wobble, gradients and plugins such as variationPlugin draw their randomness from the renderer’s seed. It defaults to 0, so every frame matches, including frames rendered in parallel tabs. To give a video a different look, pass another fixed number (seed={7}). Never pass seed="random" in a composition, because each rendering tab would pick its own seed and the handwriting would jump between frames.

boilPlugin picks its drawing from the time you pass, so it renders the same every time too. It follows the timeline’s seconds, so pass the time in seconds rather than as progress if the boil should run at video speed and go on after the text is written:

const { fps } = useVideoConfig();
<TegakiRenderer font={caveat} plugins={plugins} time={{ mode: 'controlled', value: frame / fps }} />;

Time past the end keeps the text finished, and the boil keeps cycling.

unit: 'progress' stretches or squeezes the writing to fit the composition. To keep Tegaki’s own pace (its per-stroke timing, as on a web page), pass the time in seconds and size the composition from the timeline with calculateMetadata:

import { Composition, type CalculateMetadataFunction, useCurrentFrame, useVideoConfig } from 'remotion';
import { computeTimeline, TegakiRenderer } from 'tegaki';
import caveat from 'tegaki/fonts/caveat';

const TEXT = 'What a wonderful world';
const HOLD_SECONDS = 1;

const Handwriting: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  // Past the end, the time clamps: the finished text holds.
  return <TegakiRenderer font={caveat} text={TEXT} style={{ fontSize: 120 }} time={{ mode: 'controlled', value: frame / fps }} />;
};

const calculateMetadata: CalculateMetadataFunction<Record<string, unknown>> = ({ props }) => {
  const { totalDuration } = computeTimeline(TEXT, caveat);
  return { durationInFrames: Math.ceil((totalDuration + HOLD_SECONDS) * 30), props };
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Handwriting"
    component={Handwriting}
    calculateMetadata={calculateMetadata}
    durationInFrames={1}
    fps={30}
    width={1920}
    height={1080}
  />
);

Pass computeTimeline the same timing you give the renderer (as its third argument), or the durations won’t match. The timeline’s entries also give each character’s start and duration, and strokeInstances each stroke’s, for syncing sound effects or cuts to the pen.

Arabic, Devanagari and other complex scripts

Section titled “Arabic, Devanagari and other complex scripts”

Shaping is opt-in. Fonts that need it (joining, ligatures, reordering: amiri, tillana, atma, suez-one) need the harfbuzz shaper registered once, and a composition sized from the shaped timeline, which can differ from the unshaped one by a third:

import { computeTimeline, TegakiEngine } from 'tegaki';
import amiri from 'tegaki/fonts/amiri';
import harfbuzzShaper, { createHarfbuzzShaper } from 'tegaki/shaper-harfbuzz';

TegakiEngine.registerShaper(harfbuzzShaper);

const calculateMetadata = async () => {
  const shaper = await createHarfbuzzShaper(amiri);
  const { totalDuration } = computeTimeline('مرحبا بالعالم', amiri, undefined, shaper);
  return { durationInFrames: Math.ceil((totalDuration + 1) * 30) };
};

The shaper loads asynchronously. To be sure no frame is captured before it (and the font) are ready, hold the render on TegakiEngine.preload(bundle) with Remotion’s delayRender / continueRender.

The pre-built bundles (tegaki/fonts/*) are convenient but you can use any font generated by the Tegaki generator. See Generating Font Data.