Interference
How to build interference
An inner dot orbits, and a ring dot dodges each time it passes nearby. It looks like the ring dots react to what comes close, but their motion is really one pulse repeated on a clock that keeps ticking. What looks like dodging is a distance force layered on top. Skeleton code included — the article builds up from the assembly idea.
- Published
- June 14, 2026
- Topics
- Interference · Proximity · Easing · SVG
The orbiter, and a ring that seems to dodge
- You hand-place one dot orbiting the inside at a steady speed — this is the thing that passes
- The ring dots replay one pulse, each delayed by an integer clock step (a clock that never stops)
- On top, you add a force set by the distance to the passing dot — stronger up close, falling off with the square of distance
const wrap = (frame, span) => ((frame % span) + span) % span;
// the pulse: from the peak it falls, then the exit speed seeds a small wobble that settles
const pulseExitSlope = () => {
const [, , x2, y2] = decayBez;
return ((1 - y2) / (1 - x2)) * ((0 - peak) / decayZeroTau);
};
const pulseAt = (tau) => {
const t = wrap(tau, periodFrames);
let base = 0;
if (t <= decayZeroTau) base = peak + (0 - peak) * cubicBezier(...decayBez)(t / decayZeroTau);
else if (t >= attackStartTau) base = peak * cubicBezier(...attackBez)((t - attackStartTau) / (periodFrames - attackStartTau));
const v = pulseExitSlope();
const settle = [0, 1].reduce((sum, m) => {
const d = wrap(t - decayZeroTau, periodFrames) + m * periodFrames;
return sum + (v * Math.exp(-settleLambda * d) * Math.sin(settleOmega * d)) / settleOmega;
}, 0);
return base + settle;
};pulseAt is the one pulse. Up to decayZeroTau it falls from the peak back to rest; from attackStartTau on it rises to the next peak. After it lands, the exit speed seeds a small wobble that settles, and its size isn't set separately (settleLambda, settleOmega). The fall and rise curves decayBez and attackBez are the same set as CSS cubic-bezier(), and drop straight into a ready-made library like bezier-easing.
const DEG = Math.PI / 180;
const signedDelta = (a, b) => ((((a - b) + 180) % 360) + 360) % 360 - 180;
// frame number → positions of the inner dot and the ring dots
function dotsAt(frame) {
const f = wrap(frame, periodFrames);
// inner orbiter: constant speed, no easing — the side that passes
const orbitDeg = innerStartAngle + innerSpeed * f;
const ix = cx + orbitRadius * Math.sin(orbitDeg * DEG);
const iy = cy - orbitRadius * Math.cos(orbitDeg * DEG);
const ring = restAngles.map((angDeg, k) => {
// a clock that never stops: each dot reads the same pulse, delayed by an integer clock step
const tau = wrap(f - (pulseAnchor + pulseClock * k), periodFrames);
const rPre = restRadius + pulseAt(tau);
const ux = Math.sin(angDeg * DEG);
const uy = -Math.cos(angDeg * DEG);
const delta = signedDelta(orbitDeg, angDeg);
// a distance-set force (distance includes the pulse = feedback): a push and a slide, falling off with the square
const dPre = Math.hypot(ix - (cx + rPre * ux), iy - (cy + rPre * uy));
const rAct = rPre + (pushStrength * Math.cos(delta * DEG)) / dPre ** 2;
const dAct = Math.hypot(ix - (cx + rAct * ux), iy - (cy + rAct * uy));
const slide = (-slideStrength * Math.sin(delta * DEG)) / dAct ** 2;
const effDeg = angDeg + (slide / rAct) / DEG;
return { cx: cx + rAct * Math.sin(effDeg * DEG), cy: cy - rAct * Math.cos(effDeg * DEG) };
});
return { inner: { cx: ix, cy: iy }, ring };
}This is what interference really is. The ring dots aren't reacting to the pass — they run on a clock that never stops, just reading pulseAt shifted by pulseClock per slot. In fact the gap between a pulse and the pass drifts over one lap (a reaction would hold the gap fixed). What looks like dodging is the distance force on top: a push and a slide, stronger up close and falling off with the square of the distance dPre, carry each dot away from the passing point. The distance uses the actual, pulsed position, so a dot already pushed out is shoved less — the lopsided escape comes out of that feedback.
Placing it is nine circles. Draw the one inner dot and each ring dot at the cx, cy that dotsAt returns. Draw the ring dots first and the inner dot last, so the passing dot paints on top. Drive it with requestAnimationFrame: multiply elapsed time by your frame count to get a frame number, wrap it against periodFrames, and hand it to dotsAt — 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 pulseClock. Shrink the clock and the pulse races around, and the dodge can't keep up. Raise pushStrength and slideStrength and the escape grows; change innerSpeed and the pass gets faster or slower. Swap the ring dots for squares and the same skeleton — an orbiter plus a distance force — keeps working.