Linkage
How to build linkage
While the center square makes a quarter turn, the side squares slide as if bolted to it, and near the diamond everything seems to pause. Each side's position is solved every frame as the spot that just grazes the spinning square — one rotation drives the motion, and the sides follow it by geometry. Even the pause is the contact point's progress stalling there for a moment. One rotation and a one-line contact law, written out as short functions and unpacked.
- Published
- June 10, 2026
- Topics
- Tangency · Geometry · Easing · SVG
Write one rotation and one line of law
- 3 rotation keys — upright to the diamond, then back to upright (where they sit on the timeline is yours to choose in
rotationKeys) - one easing curve per segment, two in total
- zero keyframes on the side squares
- the push distance grows from zero to its peak, then returns to zero
Now put it into code. The parts: one fill-between-keys function, the keyframes placed on the rotation, and the one-line law — measure “half the width of the rotated square” every frame, and push each side out by however much it grew over rest. That is all.
// 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 you pass to keyAt carries the curve that fills the next segment — exactly the math of CSS cubic-bezier(), and the 4 numbers you put in climbHandles and descendHandles go straight into npm's bezier-easing. Outside its range it returns the end values, so the upright stretch before the first key costs nothing.
// Only one thing moves: the center's rotation. The sides just read it through a contact law.
// Bare lowercase names (riseStart, side) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const climbCurve = cubicBezier(...climbHandles); // same standard as CSS cubic-bezier() — any 4 numbers you like
const descendCurve = cubicBezier(...descendHandles); // the return segment — one more curve, same standard
// the keyframes placed on the rotation: upright → the diamond → back upright after the quarter turn. Nothing else is keyed
const rotationKeys = [
{ frame: riseStart, value: 0, curve: climbCurve }, // → climb to the diamond
{ frame: diamondFrame, value: diamondAngle, curve: descendCurve }, // → descend to upright
{ frame: loopEnd, value: quarterTurn },
];
// frame number in, center angle and side positions out
function squaresAt(frame) {
const angle = keyAt(rotationKeys, frame);
// the one-line law: a rotated square's half-width depends on the distance from the diamond
const phi = Math.abs(((angle % quarterTurn) + quarterTurn) % quarterTurn - diamondAngle);
const halfWidth = (side / Math.SQRT2) * Math.cos((phi * Math.PI) / 180);
const push = Math.max(0, halfWidth - side / 2 - gap); // push while keeping the gap clearance
return {
center: { rotate: angle },
left: { x: leftHome - push }, // zero keys on the sides — the law is symmetric
right: { x: rightHome + push },
};
}((angle % quarterTurn) + quarterTurn) % quarterTurn folds any angle, however many turns it has made, into one stretch from zero to quarterTurn. That is why the width line is written as "distance from diamondAngle" — feed it any angle and it works.
A square of side side is exactly side wide while it stands upright. At the diamond its diagonal turns sideways and the width grows by a factor of Math.SQRT2 — that growth becomes the push, which travels from zero to its peak and back to zero. Right and left move by the same formula, symmetric, with no time offset. gap is the clearance that keeps them just short of touching.
To place it, set three squares of side side at the same height and rotate only the center one by rotate(angle). Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second to get frame, take the remainder against the loop length, and you get a loop built the same way as the demo above.
To play with it, feed in more rotation. The law repeats every quarterTurn, so a quarter turn yields 1 push and a half turn really does yield 2 — the count is never decided in advance; it follows from the rotation you feed in.