Count growth
How to build count growth
The dots seem to grow from 6 to 19 and fall back to 6. That count change is really one thing: group-staggered entrances layered onto the whole lattice opening and closing together. Offset when each group appears, and the swell in the count emerges on its own. The whole trick is written out as short functions and unpacked.
- Published
- June 10, 2026
- Topics
- Lattice · Emergence · Easing · SVG
Set up the lattice and the breath
- 6 dots that never disappear — standing on the hexagon's vertices, the base of the lattice
- 1 center, 6 inner-ring and 6 edge dots — 13 waiting for their cue
- edge dots stand between the vertices
The motor is a single number that says how far the lattice has opened — it climbs from 0 (closed) to 1 (fully open), holds for a while, and falls back to 0. Which frames it opens on, where it holds, when it closes — that whole split is yours to choose by placing the keyframes in breathKeys in the code below. Call it the breath.
What the breath directly drives is the hexagon's radius — the center-to-vertex distance. The breath value fills the gap between radiusRest (closed) and radiusPeak (fully open) as-is. The whole lattice also rotates across the same spans, but not from the breath value — the rotation keeps its own keyframes and curves, the spinKeys in the code below. To put it into code, a single part is enough: a function that fills the space between keys with a curve.
// 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
}This keyAt is nearly the only part the article needs. Each keyframe you place carries “on which frame, which value, and which curve fills the next segment”.
The curves are specified with the same 4 numbers as CSS cubic-bezier(). The cubicBezier in the code comes from import cubicBezier from "bezier-easing" — it takes the 4 numbers you place under a name like breathOpenHandles as-is. Outside the key range, keyAt returns the end values, so the stillness before and after the motion costs nothing.
// Only one thing moves: a single breath. Radius, rotation and appearance all interpolate between placed keyframes through keyAt.
// Bare lowercase names (holdStart, radiusRest) 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 — any curve, no movement
// every curve is the same standard as CSS cubic-bezier() — the numbers are yours to choose
const breathOpen = cubicBezier(...breathOpenHandles); // the breath opening
const breathClose = cubicBezier(...breathCloseHandles); // the breath closing — its own face
const spinRise = cubicBezier(...spinRiseHandles); // rotation, way out
const spinFall = cubicBezier(...spinFallHandles); // rotation, way back
const railOut = cubicBezier(...railOutHandles); // edge rail, way out
const railBack = cubicBezier(...railBackHandles); // edge rail, way back
// the breath — separate curves for opening and closing; equal values hold in between
const breathKeys = [
{ frame: openStart, value: 0, curve: breathOpen }, // the opening starts here
{ frame: holdStart, value: 1, curve: linear }, // fully open — the hold begins
{ frame: holdEnd, value: 1, curve: breathClose }, // hold over — into the close
{ frame: closeEnd, value: 0 }, // closed and resting
];
// the rotation — same frame ranges as the breath, with 2 curves of its own
const spinKeys = [
{ frame: openStart, value: 0, curve: spinRise },
{ frame: holdStart, value: 1 / 2, curve: linear }, // half the turn by the hold
{ frame: holdEnd, value: 1 / 2, curve: spinFall },
{ frame: closeEnd, value: 1 }, // the other half on the way home
];
// the edge rail — a radius round trip on its own frame ranges
const railKeys = [
{ frame: railStart, value: railRest, curve: railOut },
{ frame: railHoldStart, value: railPeak, curve: linear },
{ frame: railHoldEnd, value: railPeak, curve: railBack },
{ frame: railEnd, value: railRest },
];
const center = { x: stageSize / 2, y: stageSize / 2 }; // center of a square canvas of side stageSize
const rad = (deg) => (deg * Math.PI) / 180; // degrees → radians
// frame number in, position and size of all 19 dots out
function latticeAt(frame) {
const breath = keyAt(breathKeys, frame);
const radius = radiusRest + (radiusPeak - radiusRest) * breath; // only the radius rides the breath
const spin = spinPerLoop * keyAt(spinKeys, frame);
return dots.map((dot) => ({
angle: dot.angle + spin, // every dot rides the same rotation
dist: dot.role === "arm" ? radius // base dots — the breath's radius
: dot.role === "edge" ? keyAt(railKeys, frame) // edge dots — the dedicated rail
: dot.dist, // center and inner ring — the row's distance
scale: dot.grow ? keyAt(dot.grow, frame) : 1, // appearing and leaving are scale keys
}));
}
// from angle and distance to screen coordinates
function dotXY(dot) {
return {
x: center.x + dot.dist * Math.cos(rad(dot.angle)),
y: center.y - dot.dist * Math.sin(rad(dot.angle)), // the screen's y axis points down, hence −sin
};
}dots is the 19-row blueprint. All 19 dots exist from the first frame and not a single one is ever created — each dot just raises its scale on its own cue, and the cues land in order (center, then edge, then inner ring) a few frames apart, so the count only seems to grow. Each row carries a role, an angle, a distance, and grow — the keyframes placed for appearing and leaving — and the 6 base dots carry no grow and simply stand.
The distance written in a row is used as-is only for the center and the inner ring — for the 6 base dots the breath's radius decides it, and for the 6 edge dots the dedicated railKeys rail decides it, every frame. The angles start from the hexagon's 6 vertex directions; the base and the inner ring stand on those directions, and only the edge dots stand between them.
Placement is exactly the dotXY in the code — make the canvas a square of whatever side stageSize you choose, then walk from its center along the dot's angle by its distance and draw a circle there. The screen's y axis points down, hence the minus sign on Math.sin.
Drive it with requestAnimationFrame: multiply the elapsed seconds by your frames-per-second, take the remainder against the loop length, and hand the frame number to latticeAt — you get the same loop as the demo above. The seam stays invisible thanks to the per-loop rotation spinPerLoop: a hexagonal lattice returns to its own pose every vertex step, so picking spinPerLoop as a multiple of that step lands the last frame exactly on the first.
Only the six edge dots carry one more independent motion — a radius round trip that slides on its own frame ranges, separate from the breath. Both the destinations and the ranges are yours to place as keyframes in railKeys in the code above.
Rewrite the dots blueprint and the lattice becomes a different picture. Square up the layout and the count still seems to grow, as long as the cue stagger survives; let the breath drive opacity instead of the radius and the same skeleton wears a different face.