AI CSS Animations

Blog · · Maxwell Prehoda

Editing AI Motion Design and Exporting It to MP4

AI gets you a first cut, often a good one. But almost every animation Claude hands back has the same few problems: it's too fast to read, text runs into other text, everything moves at once and nothing ever stops. Then you need an MP4, and most people reach for a screen recorder and make it worse.

This post covers both jobs: the edits AI motion nearly always needs, and how to convert an HTML animation to video or render a Remotion MP4 without losing a frame.

The fixes AI motion almost always needs

Models pace animation to impress, not to be read. Know these going in and you can put them in your first prompt.

Pacing: add holds

The one you'll hit first. A headline lands and leaves before you've read it. The fix is holds: stretches where the thing you're meant to read just sits there. Keep every line up long enough to read twice. If that makes the video too long, cut a line. Don't speed it up.

Overlaps: every element needs its own exit

New text arrives before the old text has left, and for a few frames two lines sit on top of each other. Ask for every element to have its own exit, finished before anything else enters the same space. @twoclipping's morphing UI template calls this out: text that swaps inside a morphing container needs its own enter and exit timing, or it overlaps.

Easing: out on the way in, in on the way out

Things arriving should slow down as they land, so entrances ease out. Things leaving should speed up as they go, so exits ease in. Springs are good with very little overshoot. One small settle feels physical. Three bounces feels like a toy. Linear motion looks mechanical, and the browser's default ease looks like nobody decided. If you want something to paste into a note, cubic-bezier(0.22, 1, 0.36, 1) is a good entrance and cubic-bezier(0.32, 0, 0.67, 0) is a good exit.

Too much at once

A dashboard flies in, its numbers count up, a chart draws and the labels slide in, all in the same half second. The eye doesn't know where to go. Pick one focal element per moment and keep the rest still. When several things must arrive together, stagger them, in the order you want them read.

No rest

In AI motion, something is always drifting, pulsing or rotating. Stillness is what makes the next move land. Ask for real pauses and an end card that holds completely still. Ban idle animation by name if you have to.

The loop seam

If the video loops, the end has to match the start exactly, in position and in speed, or you get a jump every time it wraps. @twoclipping's gotcha is the right one: make the last frame identical to the first, cursor included. Check it by rendering a frame at zero and one at the full duration. They should match. (The render script below stops one frame short of the duration, so the loop doesn't show that picture twice.)

How to give notes

Vague notes get vague fixes. "Make it more dynamic" gets you everything faster and busier, the opposite of what most AI motion needs. Give a timestamp and a specific change:

0:02 - "Ship faster" is still on screen when "with less code" arrives. Give the first line its own exit and start the second after it's gone.
0:04-0:06 - the dashboard slides in at constant speed. Ease it out, cubic-bezier(0.22, 1, 0.36, 1), about 600ms.
0:09 - the chart, the labels and the legend all move at once. Draw the chart first, then stagger the labels 80ms apart.
0:16-0:20 - nothing ever stops moving. Hold the end card completely still for the last two seconds.

Three to five notes per round. More than that and the fixes start breaking each other. If a round makes things worse, ask Claude to go back. It's code, so nothing is lost. The same goes for AI video editing of real footage: @sab8a had Claude cut a raw talking-head clip into a punchy edit with subtitles, graphics and music, and an edit like that still needs someone saying what's wrong at which second.

The other habit worth building: don't review by watching a full render every time. Ask for a contact sheet first.

Before the full render, make a contact sheet: one frame per second, each scaled to 480px wide, tiled in a grid with its timestamp underneath. Save it as contact.png, look at it yourself, and fix anything cramped, overlapping or hard to read. Then show me the sheet.

For anything cut to music, use one frame per beat. @twoclipping's template has Claude do that before the full render and fix anything off the grid, cramped or hard to read. @aschapmann's Remotion prompt ends the same way: render it, check the frames yourself, and fix anything that overlaps, clips or feels rushed before showing the result. Stills catch layout problems in seconds. You still have to watch the video to judge pacing.

Don't screen-record it

Screen recording is the obvious way to export animation to MP4, and the worst. The recorder captures whatever the browser managed to paint in real time. Heavy frames get dropped. A busy machine makes the timing drift off the music. Then the recorder compresses it, and the platform compresses it again on upload.

Render it the way video software does: one frame at a time, taking as long as each needs, then stitch the frames into a video. Time comes from the frame number, not the clock.

Remotion: rendering is built in

If Claude built it in Remotion, preview it in the studio and render from the command line:

npx remotion studio
npx remotion compositions src/index.ts
npx remotion render src/index.ts MyVideo out/video.mp4

The composition ID (MyVideo here) is whatever the project registers, and the middle command lists them. Remotion already works frame by frame: components read the current frame with useCurrentFrame() and compute everything from it. If Claude slipped CSS transitions or keyframe animations into a component, they won't render reliably. Ask for them to be driven from the frame.

HTML: the seek(t) pattern

A plain HTML animation usually runs on the clock: CSS transitions, setTimeout, loops that nudge a value a little every tick. You can't ask that kind of page what frame 600 looks like. You can only wait ten seconds and hope.

So ask for a seek(t) function. The page exposes window.seek, which takes a time in seconds and sets every visual from that number alone. No transitions, no timers, no state carried over from the last frame. Call seek(3.2) and you get exactly what belongs on screen at 3.2 seconds, whatever came before. A complete page:

<!doctype html>
<html>
<body style="margin:0;width:1920px;height:1080px;background:#111;display:grid;place-items:center">
  <h1 id="title" style="margin:0;color:#fff;font:700 120px system-ui">Ship faster</h1>
  <script>
    // Every visual is computed from t, in seconds. No transitions, no timers, no state.
    const clamp = (x) => Math.min(1, Math.max(0, x));
    const easeOut = (x) => 1 - (1 - x) ** 3;
    const easeIn = (x) => x ** 3;
    const title = document.getElementById("title");

    window.seek = (t) => {
      const enter = easeOut(clamp((t - 0.5) / 0.6)); // in from 0.5s to 1.1s
      const exit = easeIn(clamp((t - 3.0) / 0.4)); // out from 3.0s to 3.4s
      title.style.opacity = enter * (1 - exit);
      title.style.transform = `translateY(${(1 - enter) * 40 - exit * 20}px)`;
    };

    // Opened normally, play in real time. The renderer adds ?render and calls seek itself.
    if (location.search.includes("render")) {
      window.seek(0);
    } else {
      const start = performance.now();
      const tick = (now) => {
        window.seek((now - start) / 1000);
        requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
    }
  </script>
</body>
</html>

Opened normally, it plays. With ?render on the URL, it waits to be told what time it is. Springs work this way too: @twoclipping's template asks for closed-form step responses, so they stay pure functions of time. Retrofitting seek(t) onto a finished animation is close to a rewrite, so ask for it in the first prompt.

Render the frames with Playwright

Playwright drives a headless Chromium. Set the viewport to the video size, load the page, then call seek and take a screenshot for every frame. Install it once:

npm init -y
npm install playwright
npx playwright install chromium

Save this as render.mjs next to index.html:

// render.mjs
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { pathToFileURL } from "node:url";

const width = 1920;
const height = 1080;
const fps = 60;
const duration = 20; // seconds

mkdirSync("frames", { recursive: true });

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width, height } });
await page.goto(pathToFileURL("index.html").href + "?render");
await page.evaluate(async () => {
  await document.fonts.ready;
});

const total = Math.round(duration * fps);
for (let i = 0; i < total; i++) {
  await page.evaluate((t) => window.seek(t), i / fps);
  await page.screenshot({ path: `frames/${String(i).padStart(5, "0")}.png` });
}

await browser.close();

Waiting for document.fonts.ready matters. Skip it and your first frames can come out in a fallback font. The render may be slower than real time. That's fine: frame 900 is always exactly 15 seconds in. Run it, then stitch the frames with ffmpeg:

node render.mjs
ffmpeg -framerate 60 -i frames/%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 out.mp4

-crf 18 is close to visually lossless. Raise it for smaller files.

If the page uses CSS animations instead

Sometimes you get a page built on CSS @keyframes with no seek function. You can still scrub it: the Web Animations API can pause every animation on the page and set its time directly. Replace the seek line in the loop with this:

await page.evaluate((t) => {
  document.getAnimations().forEach((a) => {
    a.pause();
    a.currentTime = t * 1000;
  });
}, i / fps);

That handles keyframe animations that start when the page loads. It won't catch anything JavaScript starts later on a timer, or canvas drawing loops. If frames come out wrong, ask Claude to convert the page to seek(t).

Motion blur

Frame-by-frame renders are perfectly sharp, which can make fast moves look choppy. Cameras blur motion. You can fake it the way @twoclipping's template does: render 4 subframes per frame, so 240 per second, and blend each group of four with ffmpeg's tmix filter. Swap the loop for this:

const sub = 4; // subframes per output frame
mkdirSync("sub", { recursive: true });

const total = Math.round(duration * fps * sub);
for (let i = 0; i < total; i++) {
  await page.evaluate((t) => window.seek(t), i / (fps * sub));
  await page.screenshot({ path: `sub/${String(i).padStart(6, "0")}.png` });
}

Then blend and drop to 60fps:

ffmpeg -framerate 240 -i sub/%06d.png -vf "tmix=frames=4,framestep=4" -r 60 -c:v libx264 -pix_fmt yuv420p -crf 18 out.mp4

It takes about four times as long, so save it for fast camera moves and quick morphs.

Add the music

ffmpeg -i out.mp4 -i music.mp3 -c:v copy -c:a aac -b:a 192k -shortest final.mp4

-c:v copy skips a second encode. -shortest ends the file when the shorter input runs out. If the animation was timed to a beat grid, use the exact audio file the grid came from, or the cuts will miss.

Formats and sizes

Use H.264 with yuv420p. It plays everywhere. Feed ffmpeg PNG frames without -pix_fmt yuv420p and it picks a 4:4:4 format that a lot of players, QuickTime included, won't open. The sizes worth knowing:

  • 16:9: 1920×1080, for YouTube, X, LinkedIn and websites.
  • 9:16: 1080×1920, for Reels, Shorts and TikTok.
  • 1:1: 1080×1080, square.
  • 4:5: 1080×1350, for Instagram and other feeds.

Ask for the vertical version as its own layout, not a crop. Cropping 16:9 to 9:16 throws away more than two-thirds of the frame, usually including the text. A real vertical layout stacks what sat side by side and makes the type bigger. In Remotion that's a second composition.

Starting from scratch? How to make motion graphics with Claude walks through the whole process, from setup to first prompt. For prompts that already bake in the render step, browse the Motion Prompts directory. Every entry has the creator's original video next to the prompt, so you can see what it produced before you copy it.