A working doctrine for browser-native motion graphics

MOTION IS THE MESSAGE.

Fifteen chapters on making motion graphics that explain — where every animation says exactly what the narration says, at exactly the moment it says it. Each rule below is demonstrated live, in the medium it describes.

15 chapters Rendered live · no libraries Reduced-motion path included
I
Part OneFoundation
Chapter 01 · First principle

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.

Demo 01 · caption-synced reveal
Q1
Q2
Q3
0%
RECORD QUARTER

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     },
];
Chapter 02 · Physics

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.

Demo 02 · five easings · same distance · same duration
Linearlinear — avoid for UI motion
Ease-outcubic-bezier(.16, 1, .3, 1) — your default
Ease-in-outcubic-bezier(.65, 0, .35, 1) — camera & travel
Springlinear(0, .006, .025 2.8% …) — one per scene, max
Bouncelinear(…) — physical impact, never for UI chrome

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.

120–200ms
Micro
Hovers, toggles, focus. Felt, never watched.
300–500ms
Element
Entrances, reveals, emphasis. The workhorse range.
600–900ms
Scene
Slide transitions, camera moves, morphs. Anything longer must be narrated over.
/* 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); }
Chapter 03 · Choreography

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.

Demo 03 · stagger, live
Script
Beat sheet
Board
Style frames
Build
DOM & CSS
Time
Sync beats
Polish
Ease & trim
/* 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. */
II
Part TwoPhysics & Character
Chapter 04 · Character

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.

Demo 04 · anticipation · stretch · follow-through
Flat — position only
Anticipated — with squash, stretch, settle

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 */
}
Chapter 05 · Trajectory

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.

Demo 05 · offset-path vs. translate

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. */
Chapter 06 · Mass

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.

Demo 06 · mass · directional blur · ghost trail
Light · 420ms
Heavy · 1050ms
Blurred
Ghost trail
/* 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); }
III
Part ThreeLanguage
Chapter 07 · Kinetic typography

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.

Demo 07 · semantic type animation
SNAP
FADE OUT

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.

Demo 07b · variable-axis morph · Recursive
weight · slant · mono

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);
});
Chapter 08 · The hyperframe

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.

Demo 08 · match-cut morph · shared element

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
Chapter 09 · Reveals

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.

Demo 09 · clip-path · gradient mask · iris · fade
clip-path inset — hard wipe
mask-image — soft wipe
clip-path circle — iris
opacity — says nothing

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%) }
}
Chapter 10 · Data in motion

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.

Demo 10 · line draw · bar stagger · eased counter
0%of an illustrative sample retained a narrated, animated statistic*

*A demo figure. The counter exists to show the technique, not to cite research.

IV
Part FourLight & Surface
Chapter 11 · Light

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.

Demo 11 · sweep · bloom · frost · gooey
light sweep · transform
bloom · @property length
frost · backdrop-filter
gooey · feColorMatrix

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.

Demo 11b · isoluminant vs. value contrast

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         */
}
V
Part FiveSystem
Chapter 12 · Synchronisation

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.

Demo 12 · beat-driven, scrubbable player
00:00"Every product launch has three levers."
00:02"Reach — how many people hear about it."
00:04"Message — what they hear."
00:06"Timing — when they hear it."
00:08"Get all three right, and conversion triples."
Three levers
Reach — audience size
Message — the promise
Timing — the moment
conversion
TC 00:00:00:00 PAUSED
// 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.
Chapter 13 · Scroll

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.

Demo 13 · sticky graphic · scroll-bound progress
0%
01The graphic is pinned by position: sticky — no JavaScript owns the pin.
02Progress is read once per frame, not per scroll event.
03Scroll back up. The dial unwinds exactly — nothing accumulated.
04Never size steps in vh: mobile URL bars resize the viewport mid-scroll and the whole piece jitters.
Timeline source: measuring…

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 }
}
Chapter 14 · Performance

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.

Demo 14 · main thread vs. compositor (simulated)
animating left / top — layout every frame
animating transform — compositor lane
Transform and opacity only. Fake width with scaleX, fake movement with translate.
Paint-tier effects hold still. Filters, shadows and gradients are fine as static layers — animate a transform wrapper around them, never their own parameters.
will-change, sparingly. Promote only what is about to move, then release it. Every promotion costs memory.
content-visibility: auto. Off-screen chapters skip layout and paint entirely. This page does it; pair it with contain-intrinsic-size so the scrollbar doesn't jump.
IntersectionObserver, not scroll handlers. Trigger scenes when they enter view, and stop animation loops when they leave.
Read, then write. Never interleave DOM reads and writes inside a frame — that's a forced synchronous layout on every alternation.
Respect prefers-reduced-motion. Ship a no-motion edit that lands on final states. SMIL ignores the query — you must strip those nodes yourself.
Test at 6× CPU throttle, in two engines. Filters that are free in Chromium are frequently not free in Firefox.
Chapter 15 · The build pattern

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           */