Motion graphics as code
Everything here is built the same way: one HTML file, one paused timeline, deterministic export. No video editor, no After Effects, no timeline GUI. The animation is code, so it is diffable, reviewable, re-renderable at any size, and — crucially — measurable.
That last word is the heart of this skill. Motion work invites eyeballing, and eyeballing lies. Nearly every real defect you will hit here is invisible to a glance and obvious to a measurement, and several things that look broken turn out to be the measuring script's fault. Build the habit early: when something looks wrong, get a number before you get an opinion.
The shape of the thing
project/ ├── video.html # canvas, scenes, timeline — the whole animation ├── export.js # frame-by-frame capture → MP4, all aspect ratios └── verify.js # whatever you need to assert about this piece
Copy assets/starter.html to begin. It is a complete, working animation with the engine already wired — type that enters, a recurring mark that highlights a word, and a scene that exits. Its palette and motif are placeholders; the structure is the point. Read it before writing anything; it is faster than assembling the pieces from the references.
scripts/timeline.js is the engine and scripts/physics.js the simulator — inline both into the HTML, since a single self-contained file is the deliverable. scripts/export.js and scripts/verify.js run in Node and work unchanged for any project that exposes window.__seek and window.__duration.
Why a paused timeline
CSS animations and libraries run on wall-clock time. That is fine for a website and useless for a video: to capture frame 847 you must be able to put the animation at frame 847, exactly, on demand.
So: every animation is created through the Web Animations API with fill: 'both', its absolute start time baked into the timing as delay, and immediately paused. Then
jsfunction seek(s){ A.forEach(a => { a.pause(); a.currentTime = s * 1000; }); }
positions the entire piece at any instant. The exporter walks 0 → duration in 1/fps steps and screenshots. The result is identical on any machine and does not care whether the browser can hit 60fps in real time.
This buys you something else: you can interrogate any moment. seek(7.2) then read a getBoundingClientRect(). That is what makes the verification below possible.
Workflow
1. Lay out the canvas. Fixed internal coordinates (1080 wide is a good default), scenes absolutely positioned and stacked, only the height varying per aspect ratio. Read references/craft.md before placing type — optical centering is not the same as centering the box, and getting it wrong is the single most common reason a composition "feels off" without anyone being able to say why.
2. Build the timeline. Scene by scene. Give each element one animation covering its whole life. Keep an eye on the four failure modes below; they account for most of the time lost on work like this.
3. Verify by measuring. Before showing anything, write the small script that checks the thing you are unsure about. references/verify.md has the recipes and, more importantly, the traps in the measuring scripts themselves.
4. Export. node export.js → the MP4s. references/export.md covers the encoder flags that decide whether a platform accepts your file.
The four things that will bite you
These are not style preferences. Each one produced a bug that survived visual review.
One animation per element
Entrance, hold and exit go in a single keyframe list. Two animations with fill: 'both' touching the same property collide, and the later-created one wins from time zero — so an element that should appear at 14s is visible from the first frame, with its "before" state applied backwards through the whole piece.
The symptom is bizarre and misleading: content from a later scene bleeding through an earlier one. If you see that, count the animations on the element.
seek() must pause first
A preview loop keeps playing. Set currentTime on a running animation and it advances a few milliseconds before the screenshot lands. This is not a preview annoyance — it silently corrupts every frame of the export, and the result looks plausible enough that you will ship it.
Pause, then set the time, and cancel the preview loop the first time an external seek arrives.
Keyframe times must be monotonic
When you chain moves — "then it hops here, then there" — it is easy to schedule one before the previous finished. WAAPI rejects the whole animation with Offsets must be monotonically non-decreasing, which tells you nothing about which move overlapped.
Clamp on the way in, so the times in your script are intentions rather than fragile arithmetic:
jsconst push = (t, s, ease) => { t = Math.max(t, lastT); lastT = t; /* ... */ };
The same hazard reappears in anything assembled from several sequences (a shared camera track, for instance). Clamp there too.
Measure before you animate
fill: 'both' applies the first keyframe immediately, so the moment you create the animations, every getBoundingClientRect() reflects a transform. Do all layout measurement first, then build the timeline. Ordering: build DOM → optically centre → measure targets → create animations.
Pick a motion language, then hold to it
The most common way a piece looks amateur is that different elements arrive in different ways. Choose one way things enter and one way they leave, and apply it everywhere. Which one is a tone decision, not a technical one:
| Language | Says | MG.ENTER / MG.EXIT |
|---|---|---|
| Speed — arrives fast with a directional smear and brakes | urgency, sport, news, "we move fast" | speed |
| Pop — grows from nothing and overshoots | playful, product, consumer, toy | pop |
| Wipe — a hard edge uncovers it, nothing moves | editorial, architectural, confident | wipe |
| Drift — barely moves, the fade does the work | premium, calm, luxury, serious | drift |
| Rise — comes up out of its own baseline | cinematic titles, credits | rise |
| Focus — comes into focus from blurred | photographic, dreamy, memory | focus |
They return keyframe steps, so an entrance and an exit concatenate into one list:
jstl(el, [...MG.ENTER.pop(tIn, {size: fs}), ...MG.EXIT.drift(tOut, {size: fs})]);
Nothing about the rest of this skill assumes a particular one. If a brief does not suggest a tone, ask — it is a bigger decision than any timing.
What holds regardless of language
Nothing should ever be perfectly still. A frame that holds motionless for two seconds reads as stuck, even at a flawless 60fps. Every scene gets a slow continuous push — settle over ~1.5s, then keep drifting a fraction of a percent. Around 0.7% total; at 1.4% people notice things "levitating".
Arrivals decelerate, departures accelerate. Whatever the language, matching the two curves makes everything feel rubbery.
Chained ease-outs read as stop-start. Each segment decelerates to zero and the next starts fast again. Use ease-in-out for anything continuous, and reserve the braking curve for actual arrivals.
Stagger is a lever. The same language feels completely different per-letter, per-word or per-line. Per-letter is energetic and gets tiring past a few words; per-line is calm and reads better for sentences.
Scale the exit down for large type. Exit distance is usually proportional to size, so at 112px a default sweep crosses half the screen and collides with the next scene.
A recurring element can carry the whole piece. One shape that persists across scenes gives a video a spine that cuts alone cannot — a rule that underlines the key word, a dot, a cursor, a colour block that reframes each card, a mask edge. Treat it as a character rather than a graphic: anticipation before it moves, overshoot on arrival, weight when it lands, a small idle vocabulary for when it waits. Drive any randomness from a seeded generator, because Math.random() makes the preview and the export different films.
An element that crosses text reads as a rendering bug, even when the copy explicitly calls for a strikethrough. Keep it behind the type so letters cut through it.
Dark backgrounds change things. A trail's colour, flat-area banding, and the fact that two stacked vignettes draw a visible ellipse edge. MG.trail() takes a colour for exactly this reason — hardcoded black on a dark ground is an invisible smear.
Physics, when you need it
For anything thrown, dropped or bounced, integrate the motion rather than reaching for an easing curve. An ease-out is not a fall — it decelerates, and falls accelerate. The same goes for departures, which start slow and build.
scripts/physics.js has this solved. PHYS.drop() does projectile, bounce and topple for boxes; PHYS.roll() does the same for anything round, which is a genuinely different solver; PHYS.departure() does something pulling away; PHYS.keyframes() turns any of them into keyframes. Use them rather than rewriting — the two bugs that make hand-rolled rigid bodies look wrong are already handled, and references/physics.md explains what they were.
Verify by measuring
The habit, concretely: before you claim something is fixed, write six lines that check it and print a number.
- Reproducing a reference exactly? Diff the rendered pixels, then — because pixel counts explode from sub-pixel antialiasing — also compare geometry, element by element, at matching instants. Geometry is the real claim; the pixel count is a coarse net.
- Something "feels janky"? Measure frame times before touching the design. It is often not dropped frames.
- Element flying off-frame? Sample its rect across the window and report the worst overflow.
And a warning that will save you an hour: when a check suddenly fails, suspect the check. In practice most alarms were stale measurement windows, a fixed timestamp that drifted after a retime, a comparison of elements that were invisible at that moment, or a race in the harness. Confirm the failure independently before you "fix" working code.
scripts/verify.js has each of these as a function you can import; references/verify.md explains what each one is really testing.
Export
node export.js renders 1:1, 4:5 and 9:16. Three things decide whether the file is usable:
yuv420p— without it, platforms and QuickTime refuse to decode.+faststart— without it, playback waits for the whole download.- A silent audio track — some platforms reject video-only uploads outright.
Change aspect ratio by making the canvas taller, never by padding a square with flat colour. If the design has any vignette or gradient reaching the edge, flat padding leaves a visible seam.
One trap specific to Playwright: screenshot({ animations: 'disabled' }) does not freeze anything — it fast-forwards animations to their end state, and every exported frame comes out identical. The timeline is already paused; do not pass it.
When the copy is the problem
If you are writing the words too and two rounds of alternatives all feel wrong, stop generating options. The words are usually fine — the slot has no defined job. Ask what that beat is for (name the problem? answer the objection? show proof?) and the line tends to write itself. A defensive beat dropped into the middle of a persuasive sequence will feel wrong no matter how well it is phrased.
Two related instincts: consistency between scenes beats cleverness in one of them — a scene that is twice as long as its neighbours reads as a mistake even when its content earns the time. And when someone says a section is "too slow", scale its whole sequence by a factor rather than trimming one hold; the internal rhythm is usually right.
References
Read the one you need, when you need it.
| File | What is in it |
|---|---|
references/engine.md | Timeline internals, the failure modes in depth, camera and shared tracks |
references/craft.md | Optical centering, trails, easing, recurring elements, idle vocabulary |
references/physics.md | Why the solver in scripts/physics.js is built the way it is |
references/verify.md | Measurement recipes, and the traps in the measuring scripts |
references/export.md | ffmpeg flags, aspect ratios, platform requirements |


