Follow-through
How to build follow-through
The follow-through of an i swinging right to left. The lag and the bounce are really two things: one one-way swing, and a wobble whose size is set by how fast the move leaves its last key. The faster it whips off the key, the bigger the wobble — and the lag and overshoot fall out of that. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 13, 2026
- Topics
- Follow-through · Overshoot · Easing · SVG
One swing, plus a wobble the speed creates
- The one-way swing —
keyedLegreturns one leg of the keyed move: the first key is the start, the last key the arrival, and the easing interpolates between them - The wobble the speed creates — the speed of leaving the last key
exitSpeedsets the size of the back-and-forth wobble directly (there is no separate number for how big the wobble is) - Closing the loop with one leg — the second half replays the first mirrored left-to-right, so you author only one leg;
channelAtties this together - Three channels — the stem top
topMove, stem bottombotMove, and dotdotMoveeach drive an x, andglyphAtassembles them into the stem and the dot
Two functions carry it — channelAt and glyphAt. channelAt returns one channel's position, and glyphAt calls it three times to build the stem's two ends and the dot. A few small helpers sit alongside: keyedLeg, which returns one leg of the keyed move; exitSpeed, which reads the leaving speed; and wrap, which folds a frame number back into 0〜loopFrames.
// The motor is one keyed swing plus a wobble its own speed creates — everything else is a re-read.
// Bare lowercase names (loopFrames, settleRate...) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not the values.
const wrap = (value, span) => ((value % span) + span) % span;
const exitSpeed = (move) => {
const keys = move.keys;
const last = keys[keys.length - 1];
const prev = keys[keys.length - 2];
const [, , handleX, handleY] = move.eases[move.eases.length - 1]; // the last segment's easing handles
const slope = (1 - handleY) / (1 - handleX); // the slope at the instant it leaves the key
return (slope * (last.x - prev.x)) / (last.t - prev.t);
};
const keyedLeg = (tau, move) => { // one leg of the keyed move: find which segment the time tau sits in, ease across it
const keys = move.keys;
if (tau < keys[0].t) return keys[0].x; // before the move starts — rest at the start
const seg = keys.findIndex((k, i) => i > 0 && tau < k.t); // the first key past tau
if (seg < 0) return keys[keys.length - 1].x; // after the move ends — rest at the arrival
const from = keys[seg - 1];
const to = keys[seg];
const ease = cubicBezier(...move.eases[seg - 1]);
return from.x + (to.x - from.x) * ease((tau - from.t) / (to.t - from.t));
};
function channelAt(frame, move) { // one channel's position: the one-way swing + the speed-seeded wobble. Repeats every loopFrames
const half = loopFrames / 2;
const t = wrap(frame, loopFrames);
const restA = move.keys[0].x;
const restB = move.keys[move.keys.length - 1].x;
const endTime = move.keys[move.keys.length - 1].t;
const keyed = t < half ? keyedLeg(t, move) : restA + restB - keyedLeg(t - half, move); // second half = first half mirrored — author one leg and the lap closes
const speed = exitSpeed(move);
// wobble: a back-and-forth sine shrinking to rest; its size set by the leaving speed
// sum both ends' arrivals (mirror flips the sign), reading one lap back for the leftover
const arrivals = [[endTime, 1], [endTime + half, -1]];
return arrivals.flatMap(([arrival, sign]) =>
[0, 1].map((lap) => {
const d = wrap(t - arrival, loopFrames) + lap * loopFrames;
return sign * ((speed * Math.exp(-move.settleDecay * d) * Math.sin(move.settleRate * d)) / move.settleRate);
})
).reduce((sum, ring) => sum + ring, keyed);
}
function glyphAt(frame) { // give a frame number, get the stem's two ends and the dot. Three channels each drive an x
return {
stemTop: { x: channelAt(frame, topMove), y: capTopY },
stemBot: { x: channelAt(frame, botMove), y: capBotY },
stemWidth,
dot: { cx: channelAt(frame, dotMove), cy: dotCy, r: dotRadius },
};
}Each segment carries its own easing. keyedLeg uses findIndex to find which key's segment the time sits in, then builds a cubicBezier from that segment's numbers and interpolates. These are exactly the four numbers you hand CSS cubic-bezier() — npm's bezier-easing drops straight in — so pick the shape in whatever tool you like. Before the start time it rests at the start; past the arrival it rests at the arrival.
channelAt authors the one-way swing once and closes the whole loop. The first half of the loop plays the leg straight; the second half replays it mirrored left-to-right (restA plus restB, minus the leg). So the full lap — swing right to left, a beat on the left, back to the right — comes out for half the work.
The wobble is the heart of this motion. exitSpeed takes the slope at the instant the move leaves its last key (it comes out of the handles), times the distance and time of that key, to get the leaving speed. Multiply that speed into a back-and-forth sine and the wobble shrinks as it goes and comes to rest — with no knob anywhere for how big the wobble is. So the dot, which drove hard into its stop, bounces a lot; the bottom of the stem, which eased in gently, barely bounces. The same one mechanism splits the big and small bounces apart by itself.
glyphAt calls channelAt three times. The stem top topMove, stem bottom botMove, and dot dotMove each carry their own keys and their own wobble. The lag appears because the dot's move starts later than the others — there is no separate "delay" code. The lean appears because the top and bottom of the stem sit at slightly different x only while moving; there is no curved spine. Stop, and both line up at the same spot, and the bar is straight again.
Drawing is just the stem as a thick round-capped line and the dot as a circle, in SVG or canvas. One color; stemWidth is the line thickness; the two white eyes are a decoration on the original letter, so they are not drawn here. What this can't quite reach is how the wobble splits: the three channels each carry their own settle settleDecay and settleRate, and sharing one set across all of them leaves the top end of the stem short on wobble.
To run it, build frame with requestAnimationFrame, multiplying the elapsed seconds by your frames per second. The seam stays invisible because exactly one leg closes the lap: the second half is the mirror of the first, so after one round trip the picture lands back on itself. The right end at the start of the swing and the right end at the finish hold the same pose, and dot and stem line up.
Building it with your own numbers, stagger the three keys' starts first — let the dot leave last and it bounces the most and trails the longest. Bring the end keys to nearly the same time so they read as one move, not a scatter. Raise a channel's settleDecay and the wobble settles sooner; change settleRate and the bounce gets finer or coarser. The steeper the cubicBezier handles, the faster the leaving speed, and the bigger the bounce.
channelAt returns a plain position number, so it can move things that aren't letters. The research repo's generality demo ran four channels stacked vertically on different keys and different wobbles, and the same mechanism made them follow each other like a chain — the same rule held: the later it leaves and the harder it stops, the bigger it bounces. Without a single wobble size written down, the big and small bounces line up on their own.