DEV Community

Sifat Ahmed
Sifat Ahmed

Posted on

How to Export a GSAP Animation to MP4 (No After Effects, No Screen Recording)

If you've searched "export gsap animation to mp4" or "gsap timeline to video," you've probably landed on a GSAP forum thread from years ago that never got a real answer. Here's the actual answer, with working code.

The short version: GSAP animates the DOM. A DOM doesn't export to video on its own - you need something to seek the animation to exact timestamps and screenshot each one. That's the whole trick. No After Effects, no screen recording, no proprietary timeline tool.

The core idea

Instead of playing your GSAP timeline in real time, you:

  1. Build the timeline paused, not playing
  2. Seek it to a specific time (e.g. tl.seek(1.2))
  3. Screenshot the page at that exact moment
  4. Repeat for every frame (e.g. 30 times per second)
  5. Stitch the frames into an MP4 with ffmpeg

Because step 2 is deterministic - the same seek time always produces the same visual state - step 3 can happen in a headless browser, completely decoupled from real-time playback. That's what makes this exportable at all.

Working example: a counting stat animation

Here's a real, complete composition - a number counting up, the kind of stat card you see in product demos:

<!doctype html>
<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>    <style>
      body { margin: 0; background: #0b0f14; }
#root { width: 1920px; height: 1080px; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: sans-serif; }
#stat { font-size: 220px; font-weight: 800; color: #4fd1c5; }
#label { color: #8b98a9; font-size: 36px; text-transform: uppercase; }
</style>
  </head>
  <body>
    <div id="root">
      <div id="stat">0</div>
      <p id="label">Customers Served</p>
    </div>
    <script>
      const counter = { value: 0 };
      const statEl = document.getElementById("stat");

      const tl = gsap.timeline({ paused: true });
      tl.to(counter, {
        value: 12480,
        duration: 1.6,
        ease: "power1.inOut",
        onUpdate: () => {
          statEl.textContent = Math.round(counter.value).toLocaleString();
        }
      });

      // This is what makes it exportable: a paused, seekable timeline
      window.tl = tl;
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Capturing the frames

Once you have a seekable timeline like window.tl above, exporting is mechanical: open the page in a headless browser (Puppeteer/Playwright both work), then for every frame number:

const fps = 30;
const duration = 3.6; // seconds
const totalFrames = Math.round(duration * fps);

for (let i = 0; i < totalFrames; i++) {
  const t = i / fps;
  await page.evaluate((time) => window.tl.seek(time), t);
  await page.screenshot({ path: `frame_${String(i).padStart(4, "0")}.png` });
}
Enter fullscreen mode Exit fullscreen mode

Then stitch the PNG sequence into an MP4:

ffmpeg -framerate 30 -i frame_%04d.png -c:v libx264 -pix_fmt yuv420p out.mp4
Enter fullscreen mode Exit fullscreen mode

That's the whole pipeline. The output is a real MP4, frame-accurate to the GSAP timeline, with zero screen-recording artifacts.

The five ways this silently breaks

None of these throw an error. You just get a blank render or a frozen frame and have to guess why, which is the actual reason this question keeps showing up unanswered in forums.

  1. CSS transitions racing the seek. If a property is animated by a plain CSS transition instead of by GSAP, seeking the timeline doesn't touch it - it just keeps drifting toward its own target on its own schedule. The frame you capture can be mid-transition even though your timeline thinks it's settled. Fix: drive every timed property through GSAP, and set transition: none on anything time-driven.

  2. Web fonts loading async. If a frame gets captured before a @font-face finishes loading, you get a silent fallback-font frame with no error thrown. Gate render start on document.fonts.ready.

  3. Anything animating outside GSAP's ticker. Some third-party widgets run their own requestAnimationFrame loop internally. It won't respect your paused timeline's seek - it keeps advancing on its own and drifts out of sync with the frame you think you're capturing.

  4. Non-deterministic loops. repeat: -1 never finishes, so a fixed-length export has no natural end. Compute a finite count instead: Math.max(0, Math.floor(duration / cycleDuration) - 1).

  5. Non-deterministic ordering. Object.keys() or Map iteration order on data built at runtime, feeding into dynamically generated positions - fine on screen, but can render slightly differently frame to frame if anything touches randomness or unordered iteration. Fix everything at composition-author time, not render time.

Where this goes next

The seek-and-screenshot loop above is the whole mechanism, but hand-rolling it for anything more than a single stat card gets tedious fast: multiple timed elements, audio tracks, scene transitions, sub-compositions. HyperFrames (HeyGen's open-source framework, Apache-2.0) wraps exactly this pipeline into a CLI - npx hyperframes init, author the composition, npx hyperframes render.

If you want the mental model written out in more depth, plus five more working recipes (title cards, logo intros, scene crossfades, CTA morphs, Ken Burns drift) and the full pitfalls list, I wrote it up here: https://sidheart.gumroad.com/l/lyudd (pay what you want, starting at $1).

Happy to answer questions about the render pipeline or any of the pitfalls above in the comments.

Top comments (0)