Inverse proportion
How to build inverse proportion
Two kissing circles keep trading size — one inflates exactly as the other deflates. The drawer is named 'inverse proportion', yet throughout the loop what stays constant is the sum of the radii. Keyframes sit on the top-left circle's radius — 3 of them; the other radius is that sum minus the first, and both centres are computed to recede from a fixed kiss point by their own radius, so the contact stays put. The whole build — including why the kiss point holds fixed — is written out as short functions and unpacked.
- Published
- June 10, 2026
- Topics
- Conservation · Complement · Easing · SVG
Write one radius and a one-line subtraction
- 3 radius keys —
restRadiusat both ends,midRadiusat the turnaround; where they sit on the timeline is yours to choose inradiusKeys - one easing curve per segment, two in total — the numbers go in
shrinkHandlesandgrowHandles, picked to taste - bottom-right radius: zero keys — just
radiusSumminus the top-left - both centres: zero keys — each recedes from the fixed contact point by its own radius
Despite the name "inverse proportion", what stays constant is not the two radii multiplied (their product) but added (their sum radiusSum) — so the bottom-right radius is just that sum minus the top-left. The placement of the centres is the heart of the build. Fix a single contact point on the diagonal; each circle puts its centre its own radius away from that point — the top-left circle up-left along the axis, the bottom-right circle down-right. That goes into code as-is. The only machinery it needs is a single fill-between-keys function.
// 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 hand to keyAt carries the curve that fills the next segment — the same 4 numbers as CSS cubic-bezier(), and the numbers you put in shrinkHandles and growHandles go straight into npm's bezier-easing. The shrink and the grow are similar but separate, 1 each — reuse one of them for both, and the error against the measurement grows several times over.
// Only the top-left radius is keyed. The other radius is a subtraction; both centres are computed from the contact point.
// Bare lowercase names (restRadius, contact) are numbers you pick for your own canvas —
// what this article wants you to take home is the shape, not any value.
const shrink = cubicBezier(...shrinkHandles); // same standard as CSS cubic-bezier() — the shrink segment's curve
const grow = cubicBezier(...growHandles); // the grow segment's curve — similar, but its own
// the keyframes placed on the radius: value = the top-left radius. This is everything that is keyed — where the keys sit is yours to choose
const radiusKeys = [
{ frame: 0, value: restRadius, curve: shrink }, // → shrink
{ frame: midFrame, value: midRadius, curve: grow }, // → grow — the turnaround
{ frame: loopFrames, value: restRadius }, // back to rest — the loop seam
];
// frame number in, both centres and radii out
function circlesAt(frame) {
const r1 = keyAt(radiusKeys, frame); // the top-left radius
const r2 = radiusSum - r1; // bottom-right: zero keys — one subtraction
const d = Math.SQRT1_2; // the diagonal component (= 1 / √2)
return [
{ r: r1, x: contact - r1 * d, y: contact - r1 * d }, // recede up-left from the contact
{ r: r2, x: contact + r2 * d, y: contact + r2 * d }, // recede down-right from the contact
];
}As the radii change the centres slide on their own, and the circles kiss at that point on every frame — no key ever aligns the contact. The point held still in the opening because it is built to hold still. 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.
To play with it, add keys. Grow the radius keys from 3 to 5 — restRadius→midRadius→restRadius→midRadius→restRadius — and the same function fits 2 round-trips of the trade into one loop. Change radiusSum and the pair's total changes with it — the one-line subtraction never breaks.