Difference
How to build difference
Overlapping circles open and close, and where they overlap drops out so it looks subtracted. That drop-out is really an even-odd fill rule — paint the regions covered an odd number of times — plus one clock that folds back at the mirror. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 14, 2026
- Topics
- Difference · Even-Odd · Easing · SVG
The shape that drops where it overlaps, and a clock that folds back
- You hand-place six equal circles — four filled, two as outlines
- The four filled ones are drawn with a rule that leaves out anywhere covered an even number of times — that's what makes the 'difference' look, with nothing subtracted
- All six share one clock: they open in the first half, then it folds back so the second half retraces the first in reverse
const wrap = (frame, span) => ((frame % span) + span) % span;
// the clock that folds back: it folds at the mirror, second half retraces the first in reverse
const clockAt = (frame) => {
const t = wrap(frame, periodFrames);
if (t <= mirrorFrame) return t;
return Math.max(2 * mirrorFrame - t, 0);
};
// channel shapes: a forward ramp, and a bump that rises and returns
const rampAt = (ch, t) => ch.end * cubicBezier(...ch.ease)((t - ch.t0) / (ch.endF - ch.t0));
const bumpAt = (ch, t) => {
if (t <= ch.apexF) return ch.base + (ch.apex - ch.base) * cubicBezier(...ch.e1)((t - ch.t0) / (ch.apexF - ch.t0));
return ch.base + (ch.apex - ch.base) * (1 - cubicBezier(...ch.e2)((t - ch.apexF) / (ch.endF - ch.apexF)));
};clockAt is the clock that folds back. Up to mirrorFrame time runs straight; past it the clock folds and retraces the same path in reverse — so the second half mirrors the first, and the end of the loop lands exactly back on the start. Two kinds of channel set how far things move: rampAt runs one way to a target, and bumpAt rises by one hump and returns. The curves ease, e1, e2 are the same set as CSS cubic-bezier(), and drop straight into a ready-made library like bezier-easing.
// frame number → circle positions
function circlesAt(frame) {
const t = clockAt(frame);
const u = rampAt(uChan, t); // the filled circles' spread along the axis
const v = bumpAt(vChan, t); // the filled circles' sideways bulge
const s = rampAt(sChan, t); // the outline circles' spread (lagging u slightly)
const w = bumpAt(wChan, t); // the outline circles' line width
// along a single axis, just flip the signs to place them
const rad = axisDeg * Math.PI / 180;
const ax = Math.cos(rad), ay = Math.sin(rad);
const bx = -Math.sin(rad), by = Math.cos(rad);
const place = (du, dv, r) => ({ cx: cx + ax * du + bx * dv, cy: cy + ay * du + by * dv, r });
return {
// filled circles: sign placements of the shared (u, v)
quad: [place(u, v, quadRadius), place(-u, -v, quadRadius), place(u, -v, quadRadius), place(-u, v, quadRadius)],
// outline circles: on the axis at (±s, 0)
axial: [place(s, 0, axialRadius), place(-s, 0, axialRadius)],
bandWidth: w,
};
}This is what 'difference' really is. The circles aren't subtracted from each other. The four filled ones are just equal circles, placed by flipping the signs of the shared u and v. Where they overlap, the fill lands an even number of times and drops out, and that reads as a cut-out shape. Nowhere in circlesAt is anything computing a relationship between one circle and another — the six only share clockAt's clock and four channels, never looking at each other. The first and second halves mirror because the clock folds back.
Placing it is six circles. The four filled ones go into one path and are drawn with the rule that skips anywhere covered an even number of times — that's what drops the overlap. The two outlines trace the merged outer edge as one line at the width bandWidth. Drive it with requestAnimationFrame: multiply elapsed time by your frame count to get a frame number, wrap it against periodFrames, and hand it to circlesAt — you get the same loop as the demo. Rather than hard-coding a colour, it takes the page's own ink.
To play with it, change the axis direction (axisDeg). Turn it from diagonal to vertical or horizontal and the whole opening direction changes. Make a channel's target bigger and the figure opens deeper; move the fold frame earlier and the round trip speeds up. Swap the filled circles for squares and the same idea — six circles placed by sign and merged into one — keeps working.