R RenderComp
remotion fonts typography troubleshooting tutorial

Loading Custom Fonts in Remotion: Google Fonts, Self-Hosted Files, and Flicker Fixes

Loading Custom Fonts in Remotion: Google Fonts, Self-Hosted Files, and Flicker Fixes

“My font isn’t applying.” “The first second of the video renders in the wrong typeface, then it flickers into the right one.” If you have shipped a few Remotion projects, you have probably hit one of these. Font problems are among the most common Remotion issues precisely because font loading works differently in a video renderer than it does on a normal web page.

This guide covers every font loading path in Remotion 4.x — the type-safe @remotion/google-fonts package, self-hosted files with @remotion/fonts, and the manual delayRender() pattern — plus the troubleshooting knowledge for when fonts flicker, glyphs go missing, or a Lambda render looks different from your local preview.


Why Font Loading Works Differently in Deterministic Frame Rendering

On a regular website, a late-loading font is a cosmetic annoyance: the browser paints fallback text first, then swaps in the webfont a moment later. Users barely notice.

Remotion cannot afford that. Each frame of your video is a screenshot of a headless Chromium page at a specific frame number. If the custom font has not finished loading when frame 0 is captured, the fallback font is baked into the video file permanently. There is no later “swap” — the pixels are already encoded.

This produces the two classic failure modes:

  1. The fallback flash. The font finishes loading partway through the render, so the first N frames show the fallback typeface before the video visibly “pops” into the correct font. In parallelized renders the flash can even appear mid-video, because each chunk starts its own browser instance.
  2. The silent wrong font. The font never loads (bad path, wrong family name, network failure), and the entire video quietly renders in whatever Chromium falls back to. No error, just a deliverable that looks off.

The fix for both is the same concept: block frame capture until every font is ready. Remotion exposes this through delayRender() / continueRender(), and both official font packages call these for you under the hood. Your job is mostly to use the right package and avoid patterns that bypass the blocking — which matters double for text-heavy work like kinetic typography or text reveal animations, where the typeface is the video.


Type-Safe Loading with @remotion/google-fonts

The @remotion/google-fonts package gives you a typed import per font family. Install it with:

npx remotion add @remotion/google-fonts

Then load a family and use the returned fontFamily string:

import { loadFont } from '@remotion/google-fonts/Inter';

const { fontFamily } = loadFont('normal', {
  weights: ['400', '700'],
  subsets: ['latin'],
});

export const Headline: React.FC<{ text: string }> = ({ text }) => {
  return (
    <div style={{ fontFamily, fontSize: 90, fontWeight: 700 }}>
      {text}
    </div>
  );
};

Three things to know:

  • It blocks the render automatically. loadFont() registers a delayRender() handle internally, so no frame is captured before the font is available. It also returns a waitUntilDone() function if you need a promise to await explicitly.
  • Always pass weights and subsets. Calling loadFont() with no arguments downloads every style, weight, and subset of the family. That is a lot of network requests and a common cause of render timeouts.
  • It fetches font files over the network at render time. Every fresh browser instance — every local render, every CI run, every Lambda chunk — downloads the files again from Google’s servers.

That last point is why we treat @remotion/google-fonts as the convenience option, not the production default. A render pipeline that depends on a third-party font server has an external point of failure: no offline renders, no fully reproducible builds, and an outage or firewall rule between you and a finished video. For anything you render repeatedly or sell, self-hosting is the more robust setup — and it is barely more work.


Self-Hosting Fonts with @remotion/fonts and loadFont

Available since Remotion 4.0.164, the @remotion/fonts package loads font files that live in your own project. Install it:

npx remotion add @remotion/fonts

Put your .woff2 files in the public/ folder and reference them with staticFile(). A clean pattern is a dedicated fonts.ts module that loads everything the project uses:

// src/fonts.ts
import { loadFont } from '@remotion/fonts';
import { staticFile } from 'remotion';

export const brandFont = 'Inter';

export const fontsReady = Promise.all([
  loadFont({
    family: brandFont,
    url: staticFile('fonts/Inter-Regular.woff2'),
    weight: '400',
    style: 'normal',
  }),
  loadFont({
    family: brandFont,
    url: staticFile('fonts/Inter-Bold.woff2'),
    weight: '700',
    style: 'normal',
  }),
]);

Like the Google Fonts package, loadFont() from @remotion/fonts handles delayRender() internally — you do not need to wire up blocking yourself. Import the module for its side effect and the fonts are guaranteed ready before frame capture:

import {
  AbsoluteFill,
  Sequence,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { brandFont } from './fonts'; // importing kicks off loading before frame 0

const FALLBACK = '-apple-system, "Segoe UI", Roboto, sans-serif';

const AnimatedLine: React.FC<{ text: string }> = ({ text }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const enter = spring({
    frame,
    fps,
    config: { mass: 0.6, stiffness: 160, damping: 14 },
  });
  const translateY = interpolate(enter, [0, 1], [40, 0]);
  const opacity = interpolate(enter, [0, 1], [0, 1], {
    extrapolateRight: 'clamp',
  });

  return (
    <div
      style={{
        fontFamily: `${brandFont}, ${FALLBACK}`,
        fontSize: 84,
        fontWeight: 700,
        color: '#ffffff',
        transform: `translateY(${translateY}px)`,
        opacity,
      }}
    >
      {text}
    </div>
  );
};

export const TitleCard: React.FC = () => {
  return (
    <AbsoluteFill
      style={{
        background: '#0a0a0a',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <Sequence layout="none">
        <AnimatedLine text="Self-hosted fonts" />
      </Sequence>
      <Sequence from={12} layout="none">
        <AnimatedLine text="ready before frame one" />
      </Sequence>
    </AbsoluteFill>
  );
};

Note the fallback stack after the custom family. If the font ever fails to load, a system stack like -apple-system, "Segoe UI", Roboto, sans-serif degrades gracefully instead of dropping to an arbitrary serif. For Japanese text, use a stack like "Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif.

Why .woff2? It is the smallest widely-supported format and Remotion’s Chromium handles it natively. If you only have a .ttf or .otf, convert it — smaller files mean faster render startup, especially on Lambda where each chunk loads fonts independently.


Blocking Renders Until Fonts Are Ready: delayRender() Patterns

Both packages above call delayRender() for you, but understanding the primitive helps when you need custom behavior — a font fetched from your own asset server, conditional loading based on props, or explicit error handling. The manual pattern uses the web-native FontFace API:

import { cancelRender, continueRender, delayRender, staticFile } from 'remotion';

const handle = delayRender('Loading BrandSans');

const brandSans = new FontFace(
  'BrandSans',
  `url('${staticFile('fonts/BrandSans-Regular.woff2')}') format('woff2')`,
);

brandSans
  .load()
  .then(() => {
    document.fonts.add(brandSans);
    continueRender(handle);
  })
  .catch((err) => {
    cancelRender(err); // fail the render loudly instead of shipping the wrong font
  });

The rules of delayRender():

  • Every handle must be resolved. Call continueRender(handle) on success or cancelRender(err) on failure. An unresolved handle fails the render with a timeout.
  • The default timeout is 30 seconds. If a large font on a slow connection needs longer, raise it per call: delayRender('Loading font', { timeoutInMilliseconds: 60000 }), or globally with the --timeout render flag.
  • Label your handles. The string label appears in timeout error messages, turning “a delayRender was not resolved” into “the font load is what’s stuck.”
  • Run loading at module scope, not in useEffect. A useEffect without a delayRender() handle runs after the first frames may already have been captured — this is the single most common cause of the fallback flash. Module-scope loading starts before any component renders.

If you use @remotion/google-fonts and need to sequence logic after the font is ready — measuring text, for example — use the returned waitUntilDone() promise rather than reinventing the blocking.


Weights, Subsets, and Variable Fonts

Load every weight you actually use — and nothing more. With static fonts, each weight is a separate file. If you set fontWeight: 700 but only loaded the 400 file, Chromium will synthesize a faux bold by smearing the regular outlines. It renders, but it looks subtly wrong, and it will not match design mockups made with the real bold cut.

Subsets control which characters are included. subsets: ['latin'] keeps downloads small but means accented characters from latin-ext, Cyrillic, or CJK will render as fallback glyphs or tofu boxes. Match subsets to the languages your video actually displays — including dynamic data that may arrive with unexpected characters.

Variable fonts collapse the weight problem into one file. A single variable .woff2 covers a whole weight range, which you can declare with a FontFace weight range descriptor:

const interVariable = new FontFace(
  'InterVariable',
  `url('${staticFile('fonts/InterVariable.woff2')}') format('woff2')`,
  { weight: '100 900' },
);

After document.fonts.add(), any fontWeight from 100 to 900 uses real interpolated outlines. You can even animate fontWeight frame-by-frame with interpolate() for weight-morphing text effects — something static fonts cannot do.


Japanese and CJK Fonts: File Size and Subsetting Strategies

CJK fonts are a different weight class. A single weight of a full Japanese font carries thousands of glyphs and typically weighs several megabytes even as woff2. Three strategies, in order of preference:

1. Subset to the text you render. If your composition’s text is known (or comes from a bounded data set), subset the font to exactly those glyphs with pyftsubset from fonttools:

pip install fonttools brotli
pyftsubset NotoSansJP-Regular.ttf \
  --text-file=all-strings-used.txt \
  --flavor=woff2 \
  --output-file=public/fonts/NotoSansJP-subset.woff2

A subset covering a script’s actual text is often two orders of magnitude smaller than the full font. For data-driven pipelines, regenerate the subset from your data source as a build step.

2. Subset to a character-set standard. When text is dynamic and unbounded (user input, API data), subset to a standard coverage level — kana plus the jōyō kanji covers the vast majority of modern Japanese text. Ship that as your main file and accept rare-glyph fallback.

3. Ship the full font and raise the timeout. Simplest, heaviest. Acceptable for local rendering; on distributed renders every chunk pays the load cost, so combine with a longer delayRender timeout.

A note on @remotion/google-fonts with CJK families: Google Fonts splits CJK fonts into dozens of unicode-range slices so browsers only fetch the slices they need. In Remotion this can trigger a “too many network requests” warning — the ignoreTooManyRequestsWarning option exists for exactly this case, but it is also a signal that a self-hosted subset would serve you better.


Troubleshooting: Fallback Flashes, Missing Glyphs, and Lambda Differences

The first frames show the wrong font, then it flickers. Font loading is not blocking the render. Usual suspects: loading inside useEffect without a delayRender() handle, or a hand-rolled @font-face in CSS with no blocking at all. Move loading to module scope via @remotion/fonts or the manual delayRender pattern above.

The whole video renders in a fallback font, no errors. Check three things in order. First, the family name: the string in your style={{ fontFamily }} must exactly match the family you passed to loadFont(). Second, the file path: a typo in the staticFile() path yields a 404 that FontFace.load() may surface only as a rejected promise — which is why the catch-and-cancelRender pattern beats silently continuing. Third, confirm the loading module is actually imported by the composition being rendered.

Some characters render as boxes (tofu) or in a different typeface. The loaded subset does not contain those glyphs. Common with subsets: ['latin'] plus names or data containing accented or CJK characters. Load the additional subsets, or subset your self-hosted file with wider coverage.

It looks right locally but wrong on Lambda. Your machine resolves fonts your render environment does not have. Locally, a font installed system-wide will render even if your code never loads it. The Lambda runtime ships only a minimal font set — Noto Sans in a few weights, Noto Color Emoji, and a handful of script-specific Noto fonts — so anything else must be loaded explicitly by your code. The rule: never rely on system fonts existing anywhere but your own machine. See our Remotion Lambda rendering guide for more environment differences.

Renders fail with a delayRender timeout. A font file too large for the 30-second default (common with unsubsetted CJK fonts) or a dead network path. Subset the font, self-host it so it loads from the bundle rather than a remote server, and raise timeoutInMilliseconds only as a last resort.


Font Licensing When You Ship or Sell Templates

Loading a font is a technical problem; distributing one is a legal one. If you sell Remotion templates or hand source code to clients, the font files in public/fonts/ travel with it.

  • Open licenses (SIL OFL and similar). Fonts like Inter and the Noto families are licensed so you may bundle and redistribute the files inside a larger work, commercial or not. Keep the license text alongside the font file, and note that the OFL forbids selling the font files by themselves.
  • Commercial fonts. A standard desktop license almost never includes the right to redistribute the font file to your buyers. If a template’s design depends on a commercial typeface, ship the template without the file: declare the fontFamily with a clear system fallback stack, document where buyers can license the font, and let them drop their own copy into public/fonts/.
  • Fonts from hosted platforms. Files served by hosted font services are licensed for serving, not extraction. Do not pull the woff2 out of the network tab and commit it.

The practical default for template authors: build on OFL-licensed fonts, self-host them, and include the license file in the repo. Zero friction for buyers, zero legal ambiguity for you.


FAQ

Q: Do I need to call delayRender() myself when using @remotion/google-fonts or @remotion/fonts?

No. Both packages register and resolve delayRender() handles internally. You only need manual delayRender() when loading fonts through your own mechanism, such as the raw FontFace API or a custom fetch.

Q: Can I just use a font that is installed on my computer?

It will work in local preview and local renders — and break the moment the project renders anywhere else, including Lambda and teammates’ machines. Always load fonts explicitly from files in your project.

Q: What format should my font files be?

woff2, whenever possible. It is the smallest common format and fully supported by Remotion’s Chromium. Convert .ttf/.otf sources with fonttools (--flavor=woff2).

Q: Where should loadFont() be called — component or module scope?

Module scope. Loading at the top level of an imported file starts before any frame is captured. Calling it inside a component body on every render is wasteful, and inside useEffect it is actively dangerous because the effect can run after early frames were captured.

Q: Does font loading hurt render determinism?

No — that is the point of the blocking. Once fonts are loaded before frame capture begins, every frame renders with identical font data, and re-renders produce identical pixels.

Q: How many fonts is too many?

Each family, weight, and subset adds startup time to every browser instance your render spins up. Two families with two or three weights each is a healthy ceiling for most compositions; beyond that, consider a variable font.


Summary

  1. Remotion captures frames deterministically, so fonts must be loaded before frame capture — otherwise the fallback font is baked into your video.
  2. @remotion/google-fonts is the convenient path, but it fetches from remote servers on every render. Constrain it with weights and subsets if you use it.
  3. Self-hosting with @remotion/fonts + staticFile() is the production-grade setup: no network dependency, reproducible renders, works offline and on Lambda.
  4. Both packages block rendering automatically; for custom loading, pair FontFace with delayRender() / continueRender() / cancelRender().
  5. Load real weight files (or a variable font) instead of letting Chromium fake bold; match subsets to your actual text, and aggressively subset CJK fonts.
  6. Never rely on system fonts beyond your own machine, and check redistribution rights before committing font files to a template you distribute.

Skip the setup entirely with RenderComp

RenderComp’s text-heavy Remotion templates — kinetic title cards, lower thirds, and caption systems — ship with working font loading wired in and editable TypeScript source, so you can swap in your own self-hosted typeface by changing one module.

Now available

Get 1,000+ Remotion Templates

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

View pricing →