Random
How to build random
Nine dots pop in an order you can't predict. That random-looking order is really one fixed table that writes a place-in-line into each of the nine cells. The motion itself is one keyframed clip, replayed by all nine at a steady staggered beat. The whole build — down to the spring-like undershoot that turns out to be an authored key — is written out as short functions and unpacked.
- Published
- June 10, 2026
- Topics
- Scramble · Determinism · Easing · SVG
Build exactly one clip
- pop from the rest radius
restRadiusup topeakRadius - drop too far, to
underRadius— a keyframe placed deliberately low - recover to
holdRadius, then hold it for a long stretch - one final ease back to the rest radius
restRadius
The clip is just 6 radius keyframes and 5 easing curves, one per segment — where the keys sit on the timeline is yours to choose. Put into code, it is one fill-between-keys function and 2 things you write by hand — the keyframes placed on the clip, and one order table. What looks scattered is that one order table — no random numbers, no seed.
// fill between placed keyframes with curves — every motion in this article has this shape
function keyAt(keys, frame) {
if (frame <= keys[0].frame) return keys[0].value; // at or before the first key — hold the first value
if (frame > keys.at(-1).frame) return keys.at(-1).value; // after the last key — hold the last value
const next = keys.findIndex((key) => frame <= key.frame); // index of the key we are heading toward
const a = keys[next - 1], b = keys[next]; // that key and the one before it bound the segment
const t = (frame - a.frame) / (b.frame - a.frame); // progress inside the segment, 0–1
return a.value + (b.value - a.value) * a.curve(t); // advance the gap along the curve
}Each keyframe passed to keyAt carries the curve that fills the next segment — the same 4 numbers as CSS cubic-bezier(), which npm's bezier-easing takes as-is. Outside its range it returns the end values — all the stillness before and after the clip comes from that alone.
// The moving parts: one clip, plus one table holding the order.
// Bare lowercase names (clipStart, restRadius) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const linear = (t) => t; // for segments between equal values — the hold costs nothing
// one curve per segment — each the same standard as CSS cubic-bezier(), any 4 numbers you like
const popEase = cubicBezier(...popHandles); // rest → peak
const dropEase = cubicBezier(...dropHandles); // peak → too far
const recoverEase = cubicBezier(...recoverHandles); // too far → recover
const settleEase = cubicBezier(...settleHandles); // end of hold → rest
const clipKeys = [ // value = radius. Each keyframe's curve belongs to the segment toward the next key
{ frame: 0, value: restRadius, curve: popEase }, // → pop
{ frame: peakFrame, value: peakRadius, curve: dropEase }, // → drop too far
{ frame: underFrame, value: underRadius, curve: recoverEase }, // → recover
{ frame: holdStart, value: holdRadius, curve: linear }, // → hold as long as you like
{ frame: holdEnd, value: holdRadius, curve: settleEase }, // → back to rest
{ frame: clipEnd, value: restRadius },
];
// The entire "random" is this one table — 9 cells, each holding its place in line.
// fireOrder draws no random number: you scramble it once, by hand
// frame number in, 9 radii out — the player loops by taking the remainder of frame against loopFrames (1 loop)
function radiiAt(frame) {
return fireOrder.flat().map((rank) => {
const local = frame - clipStart - rank * stepFrames; // each replay starts later by its rank
return keyAt(clipKeys, local); // fill between keys with the segment curves
});
}A segment between equal values cannot move no matter what its curve says. That is how the long hold in clipKeys is made — there is no dedicated hold machinery at all.
Placement is just 9 dots fixed on a 3×3 grid at a near-uniform pitch — only the radius moves. Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second to get frame, take the remainder against loopFrames, and you get a loop built the same way as the demo above.
The return after the pop lands exactly on the rest radius restRadius. The original video trails a faint tail that never quite returns to restRadius — but the last duplicate's clip starts latest and ends right at the loop's edge, using up nearly the whole loop. Chasing the tail would break the loop seam. So the tail was dropped, in favor of a loop whose last frame lines up exactly with its first.