Learn · Developers

How to convert an HTML animation to MP4 with Node.js

Contents

You built an animation in HTML: a CSS banner, a GSAP lower third, an animated chart. Now it has to go somewhere that only plays video, such as a social feed, an ad slot or a product page player. You need to convert the HTML animation to MP4.

Recording the browser while the animation plays gives you dropped frames and a slightly different file on every run. This guide sends the same HTML, CSS and JavaScript to the Shotstack Edit API instead. The API renders the page frame by frame and returns an MP4. The working example is one Node.js file with no dependencies.

TL;DR

  • Use the html5 asset type. The older html asset is deprecated and only rendered static markup.
  • Pass your page as three strings: html, css and js. GSAP, anime.js, D3 and Lottie are preloaded.
  • The renderer seeks every animation to the exact time of each frame, so the same input always gives the same video.
  • Inline everything. The page can’t load scripts, data, images or fonts over the network.
  • setTimeout, requestAnimationFrame and Date.now() can’t be seeked, so animations built on them capture frozen frames.

Why recording an HTML animation drops frames

CSS animations, transitions and JavaScript animation libraries run on the browser’s clock. When a headless browser records the page in real time, any stall in layout, painting or screenshot capture lets that clock run ahead. Frames go missing, and the timing drifts from one run to the next.

The reliable method is to pause the page, move every animation to the exact time of frame 1, capture it, then step to frame 2, and so on.

You can build that yourself with a headless browser and FFmpeg, converting images to video with FFmpeg at the end. You then run the browser, the frame storage and the encoder on your own servers.

Browser recordings also tend to come out as WebM, which means a second WebM to MP4 step before most platforms accept the file. The Shotstack video editing API does the frame stepping and the MP4 encoding in the cloud. You send the page in one API request.

How the html5 asset renders your animation

The html5 asset runs your page in a real browser with a JavaScript runtime. It doesn’t play the page in real time. It seeks each animation to the timestamp of every frame and captures it, so the same input always produces the same output.

The renderer drives these animation sources automatically:

  • CSS @keyframes, CSS transitions and Element.animate()
  • GSAP timelines and tweens
  • anime.js instances
  • Lottie animations loaded with lottie.loadAnimation(...)

CSS is the common case. According to the HTTP Archive’s 2022 Web Almanac, the animation property appears on 77% of mobile pages. So most animations you already have use a source the renderer can seek. GSAP, anime.js, D3 and Lottie are preloaded as globals (window.gsap, window.anime, window.d3 and window.lottie), so don’t add <script src> tags for them.

What the renderer can’t seek

Anything that depends on real elapsed time captures a frozen or wrong frame. The html5 docs list three things to avoid:

  • setTimeout, setInterval and requestAnimationFrame loops
  • Date.now(), performance.now() and new Date()
  • gsap.call(), because its callbacks don’t fire on seek

For a countdown or a ticker, build every state up front and reveal each one with a staggered CSS animation, or drive the number with a GSAP onUpdate tween. A custom animation engine can expose window.__shotstackSeek = (timeMs) => { ... }, and the renderer calls it for each frame.

What the page can’t load

The page renders under a strict Content-Security-Policy (default-src 'none'), both in the Studio preview and in the cloud render. Nothing loads from the network at render time:

  • No external scripts. Inline any library other than the four preloaded ones into js.
  • No fetch or XMLHttpRequest. Put the data in the script as a literal.
  • No remote images. Embed them as data: URIs.
  • No remote fonts. A system family such as system-ui or Arial resolves without loading a file.

Bring an animation you already have

An existing animation usually needs five changes before it renders:

  1. Split the page into three strings. The body markup goes in html, the stylesheet in css and the script in js. You don’t need <html>, <head> or <script> tags.
  2. Remove library <script src> tags. GSAP, anime.js, D3 and Lottie are already loaded. Paste any other library’s source into js.
  3. Replace timers and clock reads with a GSAP timeline or a CSS animation.
  4. Fix the page size. Set html, body to a pixel width and height with margin: 0, padding: 0 and overflow: hidden, and give the clip the same width and height. Use px, not vw, vh or % on the root.
  5. Set the clip length to the animation’s duration. The renderer doesn’t detect how long the animation runs.

Set up the Node.js project

The script uses the fetch that is built into Node.js 18 and later, so there is nothing to install. Create a project folder with this package.json:

{
  "name": "html-animation-to-mp4",
  "version": "1.0.0",
  "type": "module"
}

The script renders on production (/edit/v1), and production renders use credits (see pricing). Copy your production API key from the Shotstack dashboard and export it:

export SHOTSTACK_API_KEY="your_production_api_key"

Send the animation to the Edit API

Create index.js:

const apiKey = process.env.SHOTSTACK_API_KEY;
const env = process.env.SHOTSTACK_ENV || "v1";
const baseUrl = `https://api.shotstack.io/edit/${env}`;

if (!apiKey) {
  console.error("Set SHOTSTACK_API_KEY to your Shotstack API key.");
  process.exit(1);
}

// 1. The animation: markup, styles and a GSAP timeline, as plain strings.
const html = `
<div class="scene">
  <p class="kicker">HTML5 asset</p>
  <h1 class="headline">HTML in, MP4 out.</h1>
  <p class="subline">Rendered frame by frame from a GSAP timeline.</p>
  <div class="track"><div class="fill"></div></div>
</div>
`.trim();

const css = `
html, body {
  margin: 0;
  padding: 0;
  width: 1280px;
  height: 720px;
  overflow: hidden;
  background: #ffffff;
  font-family: system-ui, Arial, sans-serif;
}
.scene {
  box-sizing: border-box;
  width: 1280px;
  height: 720px;
  padding: 0 128px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.kicker {
  margin: 0 0 20px;
  font-size: 16px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #717171;
  opacity: 0;
}
.headline {
  margin: 0;
  font-size: 64px;
  font-weight: 600;
  letter-spacing: -0.03em;
  color: #050505;
  opacity: 0;
  transform: translateY(24px);
}
.subline {
  margin: 16px 0 48px;
  font-size: 26px;
  color: #717171;
  opacity: 0;
}
.track {
  width: 640px;
  height: 6px;
  background: #f2f2f2;
}
.fill {
  width: 100%;
  height: 100%;
  background: #25d0cd;
  transform: scaleX(0);
  transform-origin: left;
}
`.trim();

// GSAP is preloaded in the html5 renderer, so there is no <script src> tag.
const js = `
const tl = gsap.timeline();
tl.to('.kicker', { opacity: 1, duration: 0.4, ease: 'power2.out' }, 0.2)
  .to('.headline', { opacity: 1, y: 0, duration: 0.6, ease: 'power3.out' }, 0.35)
  .to('.subline', { opacity: 1, duration: 0.5, ease: 'power2.out' }, 0.7)
  .to('.fill', { scaleX: 1, duration: 2.2, ease: 'power1.inOut' }, 1.1)
  .to('.scene', { opacity: 0, duration: 0.5, ease: 'power2.in' }, 4.4);
`.trim();

// 2. The edit: one html5 clip that fills a 1280x720 frame for 5 seconds.
const edit = {
  timeline: {
    background: "#ffffff",
    tracks: [
      {
        clips: [
          {
            asset: { type: "html5", html, css, js },
            start: 0,
            length: 5,
            width: 1280,
            height: 720,
          },
        ],
      },
    ],
  },
  output: { format: "mp4", resolution: "hd" },
};

// 3. Submit the render, then poll until it is done or failed.
async function main() {
  const submit = await fetch(`${baseUrl}/render`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify(edit),
  });
  if (!submit.ok) {
    console.error(
      `Render request failed (HTTP ${submit.status}): ${await submit.text()}`,
    );
    process.exit(1);
  }
  const id = (await submit.json()).response.id;
  console.log(`Render ID: ${id}`);

  let last = "";
  for (let i = 0; i < 120; i++) {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    const poll = await fetch(`${baseUrl}/render/${id}`, {
      headers: { "x-api-key": apiKey },
    });
    if (!poll.ok) {
      console.error(
        `Status check failed (HTTP ${poll.status}): ${await poll.text()}`,
      );
      process.exit(1);
    }
    const render = (await poll.json()).response;
    if (render.status !== last) {
      console.log(`Status: ${render.status}`);
      last = render.status;
    }
    if (render.status === "done") {
      console.log(`MP4: ${render.url}`);
      return;
    }
    if (render.status === "failed") {
      console.error(`Render failed: ${render.error}`);
      process.exit(1);
    }
  }
  console.error("Gave up waiting after 6 minutes.");
  process.exit(1);
}

main();

How the script works:

  • The page. html, css and js are the whole page. The CSS pins html, body to 1280×720 and sets each element’s start state: hidden text, a 24 px offset on the heading and a bar at scaleX(0).
  • The timeline. One GSAP timeline drives every step. The last argument of each .to() call is its start time in seconds, so the bar fills from 1.1 to 3.3 seconds and the fade-out ends at 4.9 seconds, inside the 5-second clip.
  • The clip. width and height set the page’s pixel size. They match the CSS and the hd output (1280×720), so the page fills the frame.
  • The render. main() posts the edit to /render, then checks /render/{id} every 3 seconds until the status is done or failed.

Run the script

node index.js

The script prints the render ID and each status change, then the link to the MP4:

Render ID: 958cec6b-13f5-4c8e-ba84-f09de2b1acaf
Status: preprocessing
Status: queued
Status: rendering
Status: done
MP4: https://shotstack-api-v1-output.s3-ap-southeast-2.amazonaws.com/35tqpmb0ya/958cec6b-13f5-4c8e-ba84-f09de2b1acaf.mp4

That link is temporary and expires after 24 hours, so don’t store it or serve it to users. By default the render is also saved to Shotstack hosting, and you serve it from its CDN URL (see temporary files).

Here is the MP4 from that run:

The kicker and heading fade in, the teal bar fills from left to right, and the scene fades out just before the clip ends. Every frame comes from the GSAP timeline, not from a recording.

Render your web animations as video with Shotstack

You now have a Node.js script that turns an HTML, CSS and GSAP animation into an MP4 with one API call. Because the renderer seeks every frame, the output is the same on every run, and you don’t run a browser or an encoder yourself. The same request works for title cards, lower thirds and animated charts.

Sign up for a free Shotstack API key and render your first HTML animation.

Frequently asked questions (FAQs)

Can I change the text in the animation for each render?

Yes. The html string supports merge fields such as {{title}}. Add a top-level merge array next to timeline and output, with a find and a replace value for each field, and every render fills in its own text. The merging data guide shows the format.

How do I place a small animation, like a lower third, over a video?

Size the clip to the animation, not to the frame. For a 560×120 bar, set the clip’s width and height and the CSS html, body to 560×120, then move it with offset (see positioning). The page body is transparent by default, so the bar sits over the video on the track below.

Can I use a custom font?

Yes, if you embed it. The timeline.fonts array doesn’t apply to html5 assets, and the page blocks remote @font-face URLs such as Google Fonts. Base64-encode the .woff2 or .ttf file into a data: URL inside @font-face. A font family that doesn’t resolve falls back to the browser default without an error.

Keep reading

Start free

  • Unlimited developer sandbox
  • No credit card required