Claude Code Animation Not Working? The Real Fix
You asked Claude Code for an animated lower third. The preview looked great — smooth slide-in, clean fade, exactly what you described. Then you rendered, and the whole thing flickered, jumped, or came out blank.
The instinct is to blame the model. Ask it to fix the bug, get a slightly different version of the same broken output, and repeat until you give up and go back to keyframing by hand.
That instinct is wrong, and it's costing people hours. In almost every case the code is syntactically fine. What's broken is the mental model — and it's a model you were probably never given, because browser-based animation and video rendering look identical right up until the moment they aren't.
Claude Code Animation Not Working: The Root Cause
Remotion doesn't play your animation. It photographs it.
That's the whole thing. A video renderer works by taking a screenshot of each frame, then stitching the screenshots into a video file. Nothing is "running" the way a browser runs a page. There's no continuous timeline, no 60fps loop, no shared state between one frame and the next.
The Remotion docs put the consequence bluntly: "Frame 30 might be rendered before frame 10, or frame 50 might be rendered twice."
Read that again, because it invalidates a huge amount of otherwise reasonable code. Frames are rendered out of order, in parallel, across multiple browser tabs that don't share state with each other. Any animation that depends on elapsed time, on what happened in the previous frame, or on a timeline the browser is running is guessing — and it will guess wrong.
So when Claude Code writes the animation you'd write for a website, you get code that is correct for a website and broken for a video. The model isn't failing to know React. It's applying the default assumption that animation means "the browser animates it over time" — which is exactly the assumption Remotion is built to reject.
Once you see it that way, the specific failures stop looking like random bugs and start looking like one bug in three costumes.
Frame Determinism, Explained Simply
Here's the mental model worth installing: each frame must be a pure function of its frame number.
A pure function means the same input always produces the same output. If frame 42 is rendered twice, it must look identical both times. If it's rendered before frame 10, it must still look like frame 42 — not like "the first frame anyone happened to render."
The docs give a checklist worth memorising. A component should:
Always display the same visual when called multiple times
Not rely on frames being rendered in order
Not animate when the video is paused
Not rely on randomness — with the single exception of Remotion's own seeded
random()helper
A useful analogy: imagine an animation as a flipbook. Every page is drawn independently, and the pages get drawn by different illustrators working in a random order with no communication between them. Your job isn't to direct a performance. It's to make page 42 say exactly what page 42 is supposed to say, no matter who draws it or when.
Under that constraint, "animate over time" stops being a valid instruction. The only time that exists is the frame number.
Failure Mode 1 — CSS Animations and Transitions
This is the single most common cause, and Remotion has a whole page dedicated to it titled "Don't use CSS animations in Remotion."
The pattern that breaks: transition, @keyframes, and animation shorthand. These tell the browser to run a timeline of its own — but the browser doesn't know which frame is being rendered, and it has no reason to be at the right point in that timeline when the screenshot is taken. The symptoms are exactly what you're probably seeing: flickering or blank frames during rendering, or an animation that's visibly at the wrong progress.
Timer-driven code fails for the same reason. setTimeout, requestAnimationFrame, and anything that assumes a clock is ticking will produce different results on different renders.
There's a quieter trap alongside the obvious one: the docs also recommend avoiding the background-image and mask-image CSS properties, which can misbehave in a frame-by-frame render.
The fix is to derive the visual state from the frame number instead of asking the browser to animate it:
// The correct pattern: opacity is a function of the frame, not a CSS transition.
// No @keyframes, no transition, no timers — just maths on the frame number.
import {interpolate, useCurrentFrame} from 'remotion';
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: 'clamp', // hold at 1 after frame 20 instead of growing forever
});That clamp option is the detail people miss. By default interpolate() keeps extrapolating past the end of your range, so an opacity meant to stop at 1 keeps climbing — which browsers silently ignore and video renderers faithfully record.
Failure Mode 2 — State That Isn't Keyed to the Frame
The next class of bug is subtler, because it often renders almost correctly.
Anything that introduces non-determinism breaks frame independence. The docs flag randomness explicitly — use Remotion's seeded random() rather than Math.random(), so a given frame gets the same value every time it's drawn.
The same reasoning covers wall-clock values like Date.now(), and any state that carries over from one frame to another. If frame 50 is rendered twice, or rendered before frame 10, the value must come out identical every time.
Here's the diagnostic question that catches most of these: "If this component were rendered twice in a row with nothing else changing, would it look the same?" If the answer is no, you've found the bug — regardless of what the preview showed.
💡 Tip: The preview is not a test. It renders only the frames you're looking at, on a single tab, in order. The renderer renders every frame, across parallel tabs, out of order. Passing the preview tells you almost nothing about whether the render will work.
That preview-versus-render gap is the theme of the nastiest failure mode of all.
Failure Mode 3 — Assets That Weren't Loaded Yet
This one produces the symptom people find most maddening: the animation works perfectly in the preview and comes out blank or half-loaded in the render.
The cause is that the renderer doesn't wait. As the docs put it, the renderer "does not wait for data or assets to be loaded" — so if your font, image, or fetched data hasn't arrived when a frame is screenshotted, it captures the loading state and moves on.
The reason this hides so well is that delayRender() has no effect in Studio or the Player. Preview environments don't need to block because they aren't screenshotting on a schedule. So code can look correct interactively and fail only in the render — which is exactly the class of bug you cannot catch by looking at it.
The mechanism is straightforward once you know it exists:
// Suspend the render until the font has actually loaded.
// Without this, the renderer happily screenshots the fallback font.
import {useDelayRender} from 'remotion';
import {useEffect} from 'react';
const {delayRender, continueRender} = useDelayRender();
useEffect(() => {
const handle = delayRender('Loading custom font');
document.fonts.load('700 100px Inter').then(() => continueRender(handle));
}, []);Worth knowing before you reach for it:
Call it inside a component, never at module top level, or you can block renders for unrelated compositions.
Prefer the
useDelayRender()hook over the bare import — the docs recommend it because it avoids stale and duplicate handle bugs.The default timeout is 30 seconds. Miss it and the render fails with a timeout error rather than hanging forever, which is usually a mercy.
Use
cancelRender()on unrecoverable failure so a broken task doesn't hold the render open.Media components already handle this.
<Img>,<Video>,<Audio>,<Html5Video>,<Html5Audio>, and<IFrame>calldelayRender()internally — which is why they exposedelayRenderRetriesanddelayRenderTimeoutInMillisecondsprops.For data fetching, consider
calculateMetadata()instead. It runs once rather than once per concurrency, and needs no manualcontinueRender().
If you take one thing from this section: a blank render is usually a timing bug, not a drawing bug.
The Correct API
Three primitives cover nearly everything you'd want to animate. All three read from the frame, so all three are deterministic by construction.
// The three building blocks of frame-driven animation in Remotion.
import {interpolate, spring, useCurrentFrame, useVideoConfig} from 'remotion';
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
// 1. interpolate — map a frame range onto a value range
const x = interpolate(frame, [0, 30], [-200, 0], {extrapolateRight: 'clamp'});
// 2. spring — physics-based motion that settles naturally
// Overshoots slightly by default, which is what makes it feel alive
const scale = spring({frame, fps});
// 3. useCurrentFrame — the only clock you're allowed to trust
const rotation = frame * 2;The mapping is simple: reach for interpolate() when you know the exact start and end values, spring() when you want motion that settles like something physical, and useCurrentFrame() whenever you need the raw number. For anything that must not start until a later moment, wrap it in so it's given a local frame count.
🚀 Pro tip: Bake a shared timing helper into your project — a single
theme.tsexporting your durations, easings, and stagger offsets — and have every component read from it. When the client asks for "slightly faster", you change one number instead of hunting through twenty components. It also gives Claude Code a single file to reason about, which measurably reduces how often it invents timing values.
A Pre-Render Checklist
Before you render anything you intend to keep, scan for these. Each one maps to a real failure above.
[ ] No
transition,@keyframes, oranimationCSS anywhere[ ] No
setTimeoutorrequestAnimationFramedriving visual state[ ] No
Math.random()— using Remotion's seededrandom()instead[ ] No
Date.now()or other wall-clock reads affecting output[ ] Every animation derives from
useCurrentFrame()[ ]
interpolate()calls clamp at the ends where they should hold[ ] Fonts, images, and fetched data are wrapped in
delayRender()[ ] No reliance on
background-imageormask-image[ ] You rendered a real file, not just watched the preview
That last item is the one that matters most. Most of these bugs are invisible in the preview by design.
Prompting Claude Code So This Stops Happening
You can fix a lot of this at the source, because the failure is a missing constraint rather than a capability gap.
Install Remotion's agent skills first. Remotion publishes official skills that teach an agent its rendering model — the component architecture, the animation APIs, and the timing rules. Run this inside your project directory:
# Installs Remotion's agent skills into the project
# Skills land in .agents/skills, with .claude/skills symlinked for Claude Code
npx skills add remotion-dev/skillsThen state the constraint explicitly in your prompt. Vague briefs get browser-shaped code; constrained briefs get render-safe code. Something like:
Build a 5-second animated lower third. Drive every animation from
useCurrentFrame()— no CSS transitions, no CSS keyframes, nosetTimeout. Useinterpolate()withextrapolateRight: 'clamp'. If you load a custom font, wrap it indelayRender()and release it withcontinueRender().
And always verify by rendering, not previewing. Give the agent the actual error output or a description of the artifact and ask it to identify which of the three failure modes it is. An agent told "the render flickers but the preview is fine" is being handed the single most diagnostic clue in this entire process — and it will usually find the culprit in one pass rather than five.
If you're still deciding whether the code-first route is worth it at all, the honest answer is that code and After Effects split by task rather than competing outright — the question was never which one wins.
None of this is complicated once the underlying model clicks. Video is a sequence of still photographs, each one drawn independently, in whatever order the renderer feels like. Write code that makes each photograph correct on its own terms, and the flickering stops.
The rendering model was never hiding — it's documented, in detail, and the docs are genuinely good. What's missing is the assumption you walk in with. Hand the agent that assumption up front, and most of these bugs never get written in the first place.
