Afterimage
How to build afterimage
While 2 dots make one lap around a circle, the tail splits into flip-book frames in the fast stretch, melts into one band in the slow stretch, and vanishes the moment they stop. Each layer of the tail is the dot's own past — the same rotation replayed a fixed beat later per copy. One rotation drives it all, and whether the tail splits or melts follows the rotation's speed — fast and the echoes pull apart, slow and they fuse. One rotation and 9 copies, written out as short functions and unpacked.
- Published
- June 11, 2026
- Topics
- Echo · Time Shift · Easing · SVG
Set up one rotation and 9 copies
- dot radius
dotRadius— 2 of them, on exactly opposite sides of the center, rest angles listed inrestAngles - orbit radius
orbitRadius - 2 rotation keys — the rest angle at the start, exactly one full lap by frame
sweepFrames, one easing curve - frames
sweepFramestoloopFrames: rest, no rotation keys there copiescopies — 9 in the demo above, each onedelayFramesfurther into the past, eachfadePerCopy× the opacity
That's every part. Assembly is one function: pass a frame number, get back the list of circles to draw — compute 1 rotation, and let the copies read it shifted into the past.
// Only one thing moves: a single rotation. Everything else re-reads it.
// Bare lowercase names (copies, delayFrames) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const ease = cubicBezier(...easeHandles); // same standard as CSS cubic-bezier() — pacing from numbers you like
const rad = (deg) => (deg / 360) * 2 * Math.PI; // degrees to radians
// placement numbers are yours too — orbit center (center), orbit radius (orbitRadius), dot radius (dotRadius).
// restAngles lists the rest angles of the 2 dots on opposite sides
// frame number in, list of circles out (loops every loopFrames frames)
function dotsAt(frame) {
return restAngles.flatMap((rest) =>
Array.from({ length: copies + 1 }, (_, copy) => { // 0 = lead, then the copies
const past = frame - copy * delayFrames; // each copy reads slightly into the past
const t = Math.min(Math.max(past / sweepFrames, 0), 1); // progress, clamped to 0–1
return {
angle: rest + 360 * ease(t), // one full lap
opacity: fadePerCopy ** copy, // fadePerCopy× thinner per copy
};
})
);
}ease holds the entire face of this motion. It is exactly the math of CSS cubic-bezier(), and the 4 numbers you put in easeHandles go straight into npm's bezier-easing.
The curve's shape: a deep slow start, a speed peak near the middle of the sweep, then a long soft landing. Change the 4 numbers and only the pacing changes — the tail logic stays.
To put it on screen, turn each angle into a point on the orbit — center center for both x and y, radius orbitRadius: x = center + orbitRadius * Math.cos(rad(angle)), y = center + orbitRadius * Math.sin(rad(angle)). Both are numbers you pick for your own canvas, and rad() is the one-liner that converts degrees to radians.
Then drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second and take the remainder against loopFrames for the frame number — a loop built the same way as the demo above. SVG circle or canvas, either works.
The best part of this build: rotation doesn't have to be the star. Swap the angle line for a horizontal slide or a scale pulse, and the structure — stacked copies each reading slightly into the past — keeps working. Any looping motion grows a tail by the same logic.