The animation is the voiceover, made visible.
In an explainer, motion is not decoration on top of narration — motion is a second narrator. Whatever the voice claims, the screen must prove at the same instant.
The test: mute the audio. If a viewer can still follow the argument, the motion is doing its job. Watch the demo — the "voiceover" appears as captions, and every visual event lands on the word that earns it.
How long to hold a beat. Broadcast captioning targets 12–17 characters per second, with a hard floor of 0.8s no matter how short the line. Budget each beat at max(0.8s, words / 3) and never let a caption sit past ~6s without a visual change.
// Doctrine: script first, motion second. // Break the narration into beats, then attach one visual action per beat. const beats = [ { t: 0, vo: "For two quarters, growth was flat.", act: showFlatBars }, { t: 2200, vo: "Then Q3 happened.", act: growHeroBar }, { t: 4300, vo: "Up 240 percent.", act: popBadge }, ];
Easing is credibility. Duration is emphasis.
Nothing real moves linearly. Ease-out for entrances — arrive fast, settle gently. Ease-in-out for travel. Springs only when you want the audience to feel a snap of delight.
Run the lanes. Linear reads as robotic; ease-out reads as intentional; the spring overshoots — powerful once, exhausting always. The curve beside each name is the actual function, plotted.
Bezier for choreography, spring for interaction. A bezier has an authored duration, so it can be frame-synced to narration. A spring's duration is emergent — but it recalculates from current velocity, so it survives being interrupted mid-flight. Choreograph with beziers; respond to gestures with springs.
/* linear() is a real spring, sampled — not a bezier pretending. Eleven points reads as "springy"; ~25 with explicit time stops is indistinguishable from the real solution. Generate them; never hand-invent them. */ --e-spring: linear(0, .006, .025 2.8%, .101 6.1%, .539 18.9%, .721 25.3%, .849 31.5%, .937 38.1%, .991 45.7%, 1.006 50.1%, 1.015 55%, 1.017 63.9%, 1.001); /* Exits run at roughly half the entrance duration. Leaving should be quicker than arriving — the audience has already read the thing. */ .panel { transition: transform 420ms var(--e-out); } .panel.leaving{ transition: transform 200ms var(--e-in); }
One idea per beat. Stagger the rest.
The eye can follow exactly one moving thing. When five things must appear, they enter as a phrase — offset by 40–100 ms each — so the audience reads them in order instead of flinching.
Drag the offset to zero and press replay: the dump is a flinch. Push it past ~150 ms and the phrase stops being a phrase — it becomes a queue, and the viewer gets bored before the last card lands.
/* The whole trick is one custom property, multiplied by an index. */ .card { animation: cardIn 700ms var(--e-out) forwards; animation-delay: calc(var(--i) * var(--stagger)); } /* Cap a stagger group at 4–6 items. Past that the offsets read as confetti rather than as a sentence, and you should be revealing the group as one object instead. */
Anticipate, then follow through.
A move that starts at its destination has no author. Real motion winds up before it leaves and settles after it lands — a 90 ms dip against the direction of travel, then a small overshoot that resolves.
Both pucks travel the same distance in roughly the same time. The left one simply arrives. The right one crouches, launches, stretches along its vector, overshoots, and settles — and reads as a decision rather than a state change.
Where this is wrong. Anticipation costs latency. On a user-initiated action — a button press, a drag — that wind-up reads as lag, not craft. Reserve it for motion the system initiates. And keep squash-and-stretch off anything the viewer should read as rigid: applying it to a data bar or a technical diagram trades away exactly the credibility an explainer needs.
/* Multi-phase keyframes carry their own easing per segment — this is what a single transition can never express. */ @keyframes antiJump { 0% { transform: translateY(0) scale(1, 1); animation-timing-function: var(--e-io) } 16% { transform: translateY(7px) scale(1.10, .88); animation-timing-function: var(--e-out) } /* wind up */ 55% { transform: translateY(-80px) scale(.92, 1.12); animation-timing-function: var(--e-io) } /* stretch along travel */ 78% { transform: translateY(-66px) scale(1.03, .96) } /* overshoot */ 100% { transform: translateY(-72px) scale(1, 1) } /* settle */ }
Nothing in the world travels in a straight line.
Biological and physical motion pivots — around joints, around gravity. Straight-line travel is the signature of a machine. Give any positional move an arc of roughly 8–15% of its travel distance.
CSS Motion Path makes this a one-liner: offset-path takes the curve, offset-distance animates along it, and offset-rotate: auto banks the object into the turn. It is Baseline — ship it without a fallback.
The pathLength trick. Set pathLength="100" on any path and the browser rescales every length-dependent value as if the path were exactly 100 units long — so stroke-dasharray:100; stroke-dashoffset:100 draws any path from nothing, with no getTotalLength() call and no per-path JavaScript. The line above uses it.
.flier {
offset-path: path("M 40 210 C 150 210 190 40 330 40 C 430 40 470 130 560 130");
offset-rotate: auto; /* bank into the turn */
animation: fly 1.9s var(--e-io) forwards;
}
@keyframes fly { from { offset-distance: 0% } to { offset-distance: 100% } }
/* offset-rotate: auto 45deg adds a fixed correction on top of the tangent —
for glyphs that aren't drawn pointing along +x. */
Weight is communicated by duration, not by size.
Apparent mass is the single thing most amateur motion gets wrong. Heavy things take longer to start and longer to stop. A large object that snaps in 200 ms reads as weightless cardboard, whatever its dimensions.
Then there's what happens during the travel. Real cameras smear fast motion across the shutter interval. The third lane fakes that two ways: a directional SVG blur, and a ghost-clone trail — subframe compositing, done by hand.
/* Directional blur: zero one axis of stdDeviation and the Gaussian only smears along the axis of travel. One pass — far cheaper than a clone stack — but axis-aligned only; there is no arbitrary-angle primitive. */ <filter id="mblur"><feGaussianBlur stdDeviation="7 0"/></filter> /* Ghost trail: N clones on the same keyframes, each delayed a few ms and faded. Four clones already reads convincingly; past ~12 you are paying linear cost for a difference nobody sees. */ .ghost { animation: shove 720ms var(--e-io) forwards, ghostFade 720ms forwards; animation-delay: calc(var(--g) * 26ms); }
Words should behave like their meaning.
In a motion graphic, type is a character. "Rise" rises. "Snap" snaps. "Heavy" drops. When a word's entrance embodies its meaning, the viewer understands it before they finish reading it.
Four words, four behaviours, each chosen by the word itself rather than by a template. Note that every entrance shares one stagger unit and one easing family — inconsistency here is the fastest way a piece reads as assembled by committee.
Variable fonts push this further: instead of moving a word, you can move the letterforms themselves along weight, width, slant, and — with a face like Recursive — all the way from a proportional sans to a monospace.
The axis-reset trap. font-variation-settings has no per-axis cascade. Any axis you omit from a keyframe silently snaps to its default — so every keyframe must restate the complete axis set. And Safari clamps values outside the range declared in @font-face, so the declared range must cover everything you intend to animate to.
/* Split into letters only when the letters carry the idea. Splitting a whole paragraph per-character is a party trick, not typography. */ [...word].forEach((ch, i) => { const s = document.createElement("span"); s.textContent = ch; s.style.setProperty("--i", i); // index drives the stagger el.append(s); });
Carry one anchor across every cut.
A hard cut makes the audience re-orient from zero. Instead, keep a persistent anchor element — the hyperframe — that survives the transition and physically travels to its new role. The eye rides it from scene to scene.
The orb below never leaves the DOM. Scene A exits upward, Scene B enters from below, and the orb morphs — position and scale — with a single composited transform. This is the match-cut, in CSS.
Scene A — the concept
The dot is small and abstract: "imagine a single data point."
Scene B — the payoff
The same dot, grown and repositioned, becomes the subject of the next sentence: "…and this point is your customer." Nothing was cut. Everything was carried.
When to reach for View Transitions instead. document.startViewTransition() will do this geometry for you across an entire DOM swap — ideal when the two states are separate renders and you don't want to hand-thread the anchor. Choose the manual transform when you need per-property easing, interruptibility, or several elements each on their own physics. Same-document view transitions went Baseline in October 2025; still guard the call.
/* FLIP in spirit: the anchor moves by transform only — no layout, no paint. */ .orb { transition: transform 820ms var(--e-snap); will-change: transform; } .stage.state-b .orb { transform: translate(520px, 152px) scale(2.15); } /* The platform-native alternative — the browser interpolates the geometry: */ .thumb, .detail { view-transition-name: hero; } if (document.startViewTransition) document.startViewTransition(swapDOM); else swapDOM(); // no transition, still correct
Reveal by mask, not by fade.
A cross-fade asserts nothing. A directional wipe asserts causality and sequence — this came from there, and it arrived in this order. When the reveal itself carries information, choose a mask.
Four reveals of identical content. clip-path: inset() gives a crisp architectural edge; an animated gradient mask-image feathers it; circle() irises attention to a point. The fade — bottom right — is the control, and says nothing at all.
Interpolation rules. clip-path only animates between shapes of the same function with the same vertex count — polygon() to polygon(), matching point-for-point. Mismatch the counts and the browser hard-cuts instead. The same rule governs morphing an SVG d attribute: identical command types, identical order, or nothing interpolates.
/* Hard wipe — crisp edge, reads as architecture. */ @keyframes wipeInset { from { clip-path: inset(0 100% 0 0) } to { clip-path: inset(0) } } /* Soft wipe — the mask's own gradient stops travel, feathering the edge. */ @keyframes wipeSoft { from { mask-image: linear-gradient(90deg, #000 0%, transparent 18%) } to { mask-image: linear-gradient(90deg, #000 100%, #000 118%) } }
Draw the chart the way the sentence unfolds.
A chart that appears fully formed is a picture. A chart that draws itself in narration order is an argument. The line draws left to right like a sentence, bars rise in the order they're mentioned, and the number counts so the final value lands as a conclusion.
Three techniques in one scene: stroke-dashoffset for the line draw (normalised with pathLength), staggered height transitions for the bars, and an eased requestAnimationFrame counter that decelerates into its final value instead of ticking mechanically.
*A demo figure. The counter exists to show the technique, not to cite research.
Light is how the eye finds the subject.
The effects that read as expensive are all one idea: light behaving like light. A sweep across a surface, a bloom that swells and decays, a frosted plane that refracts what's behind it. Each is cheap — if you animate a transform and leave the expensive layer still.
Four surfaces. The sweep is a skewed gradient band on a pure translateX — the cheapest convincing effect on this page. The bloom animates a registered <length>, so the glow radius genuinely interpolates instead of stepping. The frost needs all four ingredients or it reads as a grey box. The blobs merge through a gooey filter.
There is one colour rule that outranks every other: during movement, the eye tracks lightness far faster than hue. Two colours of equal value swim against each other no matter how different their hues are.
Equal lightness, opposite hue — the motion vibrates and the edge is hard to track
Same movement, ~5:1 lightness gap — legible instantly, even peripherally
/* A registered property is what makes this interpolate rather than snap. */ @property --glow { syntax: "<length>"; inherits: false; initial-value: 0px; } .chip { filter: drop-shadow(0 0 var(--glow) rgba(92,200,255,.85)); } @keyframes bloom { from { --glow: 1px } to { --glow: 26px } } /* Frost is four ingredients. Drop any one and it stops reading as glass: */ .pane { background: rgba(255,255,255,.13); /* 1 · translucency */ backdrop-filter: blur(12px) saturate(170%); /* 2 · refraction */ border: 1px solid rgba(255,255,255,.24); /* 3 · edge */ box-shadow: 0 12px 34px -14px rgba(0,0,0,.75);/* 4 · lift */ }
The timeline is the architecture.
Professional motion pieces aren't a pile of CSS animations — they're one declarative timeline: an array of beats, each with a timestamp, a caption and a state. And the renderer must be a pure function of time, so scrubbing backwards un-plays the piece exactly.
Press play, then drag the scrubber back. Nothing accumulates, nothing gets stuck half-revealed — because render(t) derives the entire DOM state from t alone. A player built on setTimeout can go forwards. Only a pure renderer can go both ways.
// The entire piece is data. Change the array, change the film. const beats = [ { t: 0, line: 0, show: "title" }, { t: 2000, line: 1, show: "reach" }, { t: 4000, line: 2, show: "message" }, { t: 6000, line: 3, show: "timing" }, { t: 8000, line: 4, show: "stat" }, ]; // render() is a pure function of t — this is the whole trick. State is // derived, never accumulated, so every t maps to exactly one frame and // scrubbing backwards is free. function render(t){ beats.forEach((b, i) => { frags[i].classList.toggle("on", t >= b.t); lines[i].classList.toggle("on", t >= b.t && t < (beats[i+1]?.t ?? Infinity)); }); } // The clock is the only stateful thing, and it only ever moves t. // All motion stays in CSS, so easing, duration and the reduced-motion // path live in exactly one place.
Scroll is a timeline you don't control.
The viewer holds the playhead. That imposes one non-negotiable rule, inherited from newsroom graphics: every scroll-driven state must be a pure function of scroll position — so scrolling back up perfectly un-plays it. Never fire-and-forget on a threshold.
Scroll inside the panel. The dial is bound to progress with no easing that isn't a function of that fraction, so it tracks your hand in both directions. Note the sticky graphic — the pin is position: sticky, pure CSS. JavaScript's only job is reading the number.
The native version. animation-timeline: view() hands this to the compositor entirely — no observer, no rAF, no JavaScript at all. It ships in Chrome and Safari 26; Firefox still has it behind a flag, so treat it as pure enhancement. Nothing breaks when it's inert: the element simply sits in its base state, which is why it's safe to layer on today.
/* Zero-JavaScript reveal, driven by the element's own visibility. */ .card { animation: reveal linear; animation-timeline: view(); animation-range: entry 0% cover 40%; } @keyframes reveal { from { opacity: 0; transform: translateY(40px) } to { opacity: 1; transform: none } } /* Guard the fallback, not the enhancement — that way support arriving later is a free upgrade rather than a code change. */ @supports not (animation-timeline: view()) { .card { opacity: 1; transform: none } }
Only transform and opacity get to move.
The browser has a fast lane. transform and opacity animate on the compositor — no layout, no paint, silky even mid-JavaScript. Animate top, left, width or margin and every frame re-runs layout on the main thread.
The top box runs on a steps() curve to dramatise what dropped frames feel like against the composited lane beneath it. This matters more than any easing choice: a stuttering 300 ms transition reads worse than a smooth 600 ms one. Jank is the loudest amateur signal there is.
scaleX, fake movement with translate.contain-intrinsic-size so the scrollbar doesn't jump.Script → board → build → time → polish.
The production order is fixed. Write the narration first. Board the key frames. Build the static end-state of every scene. Only then add time — and polish easing last, when everything else is locked.
The reusable skeleton for any narrated, browser-native motion piece. Note the first block: every duration and curve on this entire page derives from those tokens, which is why the playback-rate control in the rail can put the whole document into slow motion by writing a single number.
/* 1 — Tokens. Every duration and curve lives here, nowhere else. Deriving them from --speed is what makes global slow-mo one line. */ :root { --e-out: cubic-bezier(.16, 1, .3, 1); --e-io: cubic-bezier(.65, 0, .35, 1); --speed: 1; --t-micro: calc(160ms * var(--speed)); --t-elem: calc(420ms * var(--speed)); --t-scene: calc(760ms * var(--speed)); } /* 2 — States, not animations. CSS describes "on"; JS decides "when". */ .frag { opacity: 0; transform: translateY(16px); } .frag.on { opacity: 1; transform: none; transition: opacity var(--t-elem), transform var(--t-elem) var(--e-out); } /* 3 — One clock, one pure renderer (Chapter 12). */ const scene = (beats) => { let raf, t0; const render = t => beats.forEach((b, i) => els[i].classList.toggle("on", t >= b.t)); const tick = ts => { t0 ??= ts; render(ts - t0); raf = requestAnimationFrame(tick) }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); // every scene is cancellable }; /* 4 — Ship checklist □ mute test passes (1) □ one spring per scene, max (2) □ staggers 40–100ms (3) □ anticipation only on system motion (4) □ positional moves arc (5) □ duration encodes mass (6) □ exits at half entrance (7) □ an anchor survives each cut (8) □ reveals carry direction (9) □ data draws in narration order (10) □ value contrast, not hue (11) □ render(t) is pure (12) □ scroll states reversible (13) □ transform/opacity only (14) □ reduced-motion edit ships □ 6× throttle, two engines */