Arrangement
How to build arrangement
Nine dots gather at the centre, open into a ring, then swirl back to new places — it looks finely choreographed. You hand-author two layouts — a square grid and a ring — and the swirl and the seat-swap both fall out of those two layouts and how the dots are matched between them. Skeleton code included; we build it from scratch.
- Published
- June 13, 2026
- Topics
- Arrangement · Turntable · Easing · SVG
Two layouts, and the three moves that bridge them
- You hand-author two layouts — sharing one centre, a square grid and a ring
- Work out each dot's position from the two layouts — its distance and direction from the rest spot, its place on the ring, and the seat it takes after the turn
- Three moves bridge them — pull in to the centre and fling out, hold still in the ring shape, turn while easing each dot toward its next seat
const deg = Math.PI / 180;
const signedArc = (from, to) => ((to - from + 180) % 360 + 360) % 360 - 180;
// work out each dot's position from the two arrangements
const restRadius = restPositions.map(([x, y]) => Math.hypot(x - cx, y - cy));
const restAngle = restPositions.map(([x, y]) => Math.atan2(y - cy, x - cx) / deg);
const slotAngle = restToRing.map((slot) => ringAnglesDeg[slot]);
const angleGap = restAngle.map((a, dot) => signedArc(a, slotAngle[dot]));
const slotPoint = ringAnglesDeg.map((a) => [
cx + ringRadius * Math.cos(a * deg),
cy + ringRadius * Math.sin(a * deg),
]);
const back = -turntableDeg * deg;
const destLocal = restToRing.map((slot, dot) => {
if (dot === centerDestinationDot) return [0, 0]; // the dot whose destination is the centre aims there
const [x, y] = restPositions[ringToRest[slot]];
const lx = x - cx;
const ly = y - cy;
return [lx * Math.cos(back) - ly * Math.sin(back), lx * Math.sin(back) + ly * Math.cos(back)];
});The first block works out each dot's position from the two layouts. restToRing says which dot heads to which spot on the ring; ringToRest says which rest seat each dot sits back down in on the way home. What these two mappings decide is each dot's destination and its return seat. The shape of the swirl itself comes from how you turn things, shown later. The central pile, the swirl, the seat-swap — none is animated one by one; they fall out of the two layouts and how the dots are matched between them.
const wrap = (frame, span) => ((frame % span) + span) % span;
const [lobeIn, lobeOut] = lobeXHandles;
const swingEase = cubicBezier(lobeIn, 0, lobeOut, 1);
const lastArrival = Math.max(...arrivals);
// first move: pull each dot in toward the centre, then fling it out to its slot (one shared bulge)
const expand = (frame, dot) => {
const span =
frame <= departures[dot] ? 0 : frame >= arrivals[dot] ? 1 : (frame - departures[dot]) / (arrivals[dot] - departures[dot]);
if (dot === centerSeatDot) { // the dot resting AT the centre has no room to dip → radius eases straight out
const radius = restRadius[dot] + swingEase(span) * (ringRadius - restRadius[dot]);
const angle = slotAngle[dot] * deg;
return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
}
const dip = cubicBezier(lobeIn, -lobeDepths[dot], lobeOut, 1)(span);
const radius = restRadius[dot] + dip * (ringRadius - restRadius[dot]);
const angle = (restAngle[dot] + swingEase(span) * angleGap[dot]) * deg;
return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)];
};
// last move: one shared turn angle carries every dot from the ring toward its next seat
const turnEase = cubicBezier(...phiBezier);
const chordEase = cubicBezier(...sConBezier);
const turnAt = (frame, delay) => {
const local = frame - delay;
if (local <= windowStart) return 0;
if (local >= windowEnd) return turntableDeg;
return turntableDeg * turnEase((local - windowStart) / (windowEnd - windowStart));
};
const chordAt = (frame, delay) => {
const local = frame - delay;
if (local <= windowStart) return 0;
if (local >= windowEnd) return 1;
return chordEase((local - windowStart) / (windowEnd - windowStart));
};
function dotsAt(frame) { // frame number → where each dot goes; pick the move, read each dot
const f = wrap(frame, periodFrames);
return restPositions.map((_, dot) => {
const slot = restToRing[dot];
const delay = delaysPerSlot[slot];
if (f <= lastArrival) { const [x, y] = expand(f, dot); return { cx: x, cy: y, r: dotRadius }; }
if (f - delay < windowStart) return { cx: slotPoint[slot][0], cy: slotPoint[slot][1], r: dotRadius }; // middle move: hold still on the ring
const chord = chordAt(f, delay);
const rlx = slotPoint[slot][0] - cx;
const rly = slotPoint[slot][1] - cy;
const lx = rlx + chord * (destLocal[dot][0] - rlx);
const ly = rly + chord * (destLocal[dot][1] - rly);
const turn = turnAt(f, delay) * deg;
return { cx: cx + lx * Math.cos(turn) - ly * Math.sin(turn), cy: cy + lx * Math.sin(turn) + ly * Math.cos(turn), r: dotRadius };
});
}expand is the first move. Each dot starts moving at its own departures and stops at its arrivals: it is pulled in to the centre, then flung out to its slot. How deep it dips is set by lobeDepths.
In the last move, turnAt returns one turn angle shared by every dot, and chordAt returns how close each one has come from the ring to its next seat. The easing is all curves: the numbers you put in swingEase, phiBezier, and sConBezier are the same 4 as CSS cubic-bezier(), and go straight into npm's bezier-easing.
Placement is just nine dots, drawn at the returned cx, cy, r. Drive it with requestAnimationFrame: multiply the elapsed time by your frame count, wrap against periodFrames, and hand the frame to dotsAt — you get the same loop as the demo. Turn by turntableDeg and the ring spots land exactly back on themselves while each dot sits down in a different rest seat, so one lap closes and the loop has no visible seam.
To play with it, rewrite the layouts. Change the restToRing and ringToRest mappings and the return swirl and seating change wholesale. Raise turntableDeg and the path around the ring grows longer; tweak delaysPerSlot and the dots leave scattered or all at once. Replace the layout points with another shape, and the skeleton that joins the two layouts keeps working.