Merge & split
How to build merge & split
Eight circles on a slowly turning ring dive into the center one after another, get swallowed into one growing disc, then peel off and return to the ring. Both the merge and the split are one thing: a single gather-wait-return clip replayed by every circle with a small delay, over a ring that keeps turning at a steady pace. The melt is same-color circles overlapping, and the middle disc swells with the square root of the swallow count, so its area keeps the count honest. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 12, 2026
- Topics
- Stagger · Overshoot · Easing · SVG
Set up one clip and eight copies of a circle
- The turning ring — place
copyCountcircles evenly around the center and keep the whole ring turning byturnPerFrameevery frame, never stopping - The progress p — 0 is the home seat on the ring, 1 is dead-center; the gather-wait-return clip
progressAtreturns this p - Delayed replay — circle i reads the same clip
staggerStep× i frames late (diving in order and returning in order both fall out of this alone) - The middle disc — average each circle's swallow level
absorbAt, take the square root, and scale the full radiuscoreFullRadiusby it
No merge and no split is choreographed anywhere — there is just one clip, gather-wait-return. Three functions carry the motion, with a few small easing helpers at their side: progressAt, the clip itself; absorbAt, the swallow level; and blobsAt, which turns a frame number into every circle's placement.
// The heart is ONE clip — gather, wait, return — replayed by eight circles riding a ring that never stops.
// Bare lowercase names (gatherSpan, ringRadius...) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not the values.
const easeIn = (t) => t * t * t; // gathering: a soft departure, accelerating into the center
const easeOutBack = (t) => {
// returning: overshoot the seat a little, then settle. backOvershoot sets how far past
const u = t - 1;
return 1 + (backOvershoot + 1) * u * u * u + backOvershoot * u * u;
};
// The clip: returns the progress. 0 = home seat on the ring, 1 = dead-center. Repeats every loopFrames
function progressAt(frame) {
const t = ((frame % loopFrames) + loopFrames) % loopFrames; // wraps even negative frames into 0〜loopFrames (delayed replays can read before the start)
if (t < gatherSpan) return easeIn(t / gatherSpan); // gather
if (t < gatherSpan + holdSpan) return 1; // wait at the center
if (t < gatherSpan + holdSpan + backSpan)
return 1 - easeOutBack((t - gatherSpan - holdSpan) / backSpan); // return
return 0; // rest on the ring
}
// A gentle S-curve from 0 to 1 (the classic smoothstep)
const smooth = (s) => {
const u = Math.min(1, Math.max(0, s));
return u * u * (1 + 2 - 2 * u);
};
// Swallow level: eases to 1 over absorbWindow after landing, back to 0 over the same span on leaving
function absorbAt(frame) {
const t = ((frame % loopFrames) + loopFrames) % loopFrames;
return smooth((t - gatherSpan) / absorbWindow) * (1 - smooth((t - gatherSpan - holdSpan) / absorbWindow));
}
// Give it a frame number, get the middle disc and every circle's placement
function blobsAt(frame) {
// the turning ring: evenly spaced seats, plus turnPerFrame of rotation every frame
const ring = Array.from({ length: copyCount }, (_, i) => {
const a = ((ringStart + turnPerFrame * frame + (360 / copyCount) * i) * Math.PI) / 180;
return { x: centerX + ringRadius * Math.cos(a), y: centerY + ringRadius * Math.sin(a) };
});
// delayed replay: circle i reads the same clip staggerStep × i frames late
const localFrames = ring.map((_, i) => frame + clipShift - i * staggerStep);
// each circle: pull from its ring seat toward the center by the progress
const dots = ring.map((pt, i) => {
const p = progressAt(localFrames[i]);
return { x: pt.x + (centerX - pt.x) * p, y: pt.y + (centerY - pt.y) * p, r: dotRadius };
});
// the middle disc: full radius scaled by the square root of the mean swallow level (area tracks the swallowed count)
const share = localFrames.map((f) => absorbAt(f)).reduce((sum, a) => sum + a, 0) / copyCount;
const coreR = coreFullRadius * Math.sqrt(share);
const core = coreR > minVisibleRadius ? { x: centerX, y: centerY, r: coreR } : null;
return { core, dots };
}Only two easings are involved. The gathering side, easeIn, is the one-liner t * t * t — a soft departure that accelerates into its landing. The returning side, easeOutBack, is the classic curve that overshoots the seat and settles, with backOvershoot setting how far past it goes. CSS cubic-bezier() gives you the same face if you push one of its vertical numbers above 1, so use whatever your stack offers.
progressAt owns no keyframes at all. It checks which segment of gather-wait-return the current time sits in — using the lengths gatherSpan, holdSpan, backSpan in order — and returns the progress from that segment's formula; past the end it returns 0, and the rest of loopFrames is spent resting on the ring. clipShift adjusts where playback starts, so the loop's first frame lands on the everyone-at-the-center state.
The middle disc is absorbAt's job. It is a swallow level that eases to 1 over absorbWindow once a circle lands, and back to 0 over the same span as it leaves; average it over the eight circles, take the square root, and scale the full radius coreFullRadius by it. The square root is area arithmetic: a circle's area goes with the square of its radius, so this makes the area track the swallowed count exactly — each swallow adds one eighth of the full disc.
The drawing side is nothing but circles. Put a circle wherever blobsAt says — SVG or canvas, either works. The move that matters is painting everything one color: the paint does the melting for you, so no code ever tracks which circle is touching which. One thing plain overlap can't reach: at the instant of contact the real reference smooths the neck into a gooey rounded waist. minVisibleRadius is the cutoff that skips drawing the middle disc on frames where it would be too small to see.
The overshoot on the way back decides the motion's expression. A circle spat out of the disc sails past its ring seat, drifts a little outward, then settles in. That is easeOutBack's only job — and most of why this never reads as a plain rewind.
To run it, use requestAnimationFrame and multiply the elapsed seconds by your frames-per-second to get frame. This demo's loop seam hides behind two tricks. Over one loop the ring turns exactly half a revolution — and since the eight circles are evenly spaced, the half-turned ring lands exactly on itself. Better yet, at the seam every circle is underwater at the center, so the swap stays hidden under the big disc — each lap, every circle quietly takes the seat across from its old one, and nobody sees it.
Building it with your own numbers, keep two promises. Make turnPerFrame × loopFrames a whole multiple of the seat spacing (360 divided by copyCount) — half a revolution in this demo. And give clipShift a value that parks the loop's first frame where the last circle has finished diving and nobody has left yet — anywhere from gatherSpan + staggerStep × (circle count − 1) up to gatherSpan + holdSpan.
progressAt returns a plain 0-to-1 number, so you can point it at anything, not just position. In the research repo's generality demo the same clip ran five squares with a different delay and different segment lengths, the progress feeding position, size, and rotation at once — and the gather-wait-return rhythm survived intact. Trim holdSpan and it turns hasty; stretch backSpan and the return turns graceful. Just keep holdSpan longer than the total delay if the all-swallowed moment should survive.