Symmetry
How to build symmetry
4 bars stretch open to the left and right, the center square swells, all of them freeze together at full stretch for a beat, then walk the same road home. This perfectly rehearsed-looking move is driven by a single number that climbs from 0 to 1, pauses, and returns to 0. Every shape reads that number times its own motion amount, and the mirror is the sign of that amount flipping between left and right. The center square even makes a half turn on the same number. One number and the per-shape motion amounts, written out as short functions and unpacked.
- Published
- June 11, 2026
- Topics
- Pulse · Hold · Easing · SVG
Set up one pulse and 5 shapes
- 4 tall bars — width from
w, round caps, resting height fromh - a rounded square in the middle — resting size from its own
wandh, corners rounded just slightly - 2 outer bars: heights grow by
growHand slide outward byslide— give these 2 bars the largest motion amounts - 2 inner bars: the same
growHandslide, kept gentler than the outer pair - the center square:
growWandgrowHswell both sides,spingives the half turn - 4 pulse keys — running 0 → 1 → (stays 1) → 0; where they sit on the timeline is yours to choose in
pulseKeys
That's every part. Assembly is one function: pass a frame number, get back where each shape goes — compute the pulse once, and let the 5 shapes read it through their own motion amounts.
// Only one thing moves: a single pulse. Every shape just reads it through its own motion amounts.
// Bare lowercase names (riseStart, barW) 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() — any 4 numbers you like
// keyframes placed on the pulse: 0 → 1 → (stays 1) → 0, gaps filled by ease
const pulseKeys = [
{ frame: riseStart, value: 0, curve: ease }, // the stretch starts here
{ frame: holdStart, value: 1, curve: ease }, // fully stretched — the held pause begins
{ frame: holdEnd, value: 1, curve: ease }, // pause over — the same curve simply runs downhill
{ frame: loopEnd, value: 0, curve: ease }, // back to rest
];
// 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
}
// per-shape motion amounts: where it sits, and how far one pulse moves it.
// Outer bars get the bigger amounts; left and right differ only in slide's sign — the mirror is that sign
const shapes = [
{ x: outerLeftX, w: barW, h: barH, growW: 0, growH: outerGrow, slide: -outerSlide, spin: 0 },
{ x: innerLeftX, w: barW, h: barH, growW: 0, growH: innerGrow, slide: -innerSlide, spin: 0 },
{ x: centerX, w: squareW, h: squareH, growW: squareGrow, growH: squareGrow, slide: 0, spin: halfTurn },
{ x: innerRightX, w: barW, h: barH, growW: 0, growH: innerGrow, slide: innerSlide, spin: 0 },
{ x: outerRightX, w: barW, h: barH, growW: 0, growH: outerGrow, slide: outerSlide, spin: 0 },
];
// frame number in, placements out (loops every loopFrames)
function shapesAt(frame) {
const pulse = keyAt(pulseKeys, frame % loopFrames);
return shapes.map((s) => ({
cx: s.x + s.slide * pulse, // horizontal position — left gets minus, right gets plus
cy: stageSize / 2, // vertical stays fixed — mid-canvas height
width: s.w + s.growW * pulse,
height: s.h + s.growH * pulse,
angle: s.spin * pulse, // only the center turns, up to halfTurn
}));
}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 starts deep and slow, rushes through the middle, and lands on a long soft tail. The way out — the first span of pulseKeys — is this one curve. The way back — the last span — is the same curve again: the key values run from 1 down to 0, so the same progression simply runs downhill in value. That's why going and returning wear the same face.
In between, the middle 2 keys both read 1, and the value stays put across that span. That held pause is the showpiece of this motion: change the 4 numbers of ease and only the pacing changes, while the keyframes you placed keep guarding the pause.
To put it on screen, make the viewBox a square of whatever side stageSize you choose and hand the values to a rect — the top-left corner sits half a size away from the center (x = cx - width / 2), a corner radius rx of half the bar's width makes the round cap, and the square takes a smaller rounding. Rotation in SVG is 1 line, transform="rotate(angle cx cy)" — the shape spins about its own center.
Then drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second, take the remainder against loopFrames, and you get a loop built the same way as the demo above. The pulse lands back on 0 at its last key and stays there across the loop edge — the seam hides inside the resting stretch.
The per-shape motion amounts are the playground. Grow the row to 6 bars, give every slide the same sign so the whole row drifts one way, hand spin to the outer bars — the one-pulse structure keeps working. Point the pulse at opacity or color intensity instead, and the logic still holds. Wiring everything you want moving together to the same single number is the heart of this build.