Sync Orbit
Two rings of points turning at different rates that catch and hold when coupled.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Sync Orbit
*
* Two rings of points turning at different rates. As the coupling
* between them is raised they slip more and more slowly, hang almost in
* step, slip once more — and then catch and hold.
*
* The technique: nothing tweens the two rates together. The inner ring's
* rate is nudged by how far out of step it currently is,
* dφ/dt = Δω − K·sin(Mφ), and lock is then something the system does
* rather than something the animation announces. It happens exactly when
* the coupling K can cover the rate difference Δω, and not before —
* simulated here, the rings lock at K ≥ 0.30 rad/s and never below it.
*
* That threshold is what buys the good part. Just under it the slip rate
* collapses at the alignment and races between them — 0.087 of its peak
* in simulation — so the rings visibly hang together, give up, and hang
* again. That beat is the sound of two things nearly in sync, and it is
* free: it is the same equation, not a second animation layered on.
*
* Locked is not identical, and that is correct too. The loop settles at
* whatever standing phase error it needs to make up the rate difference,
* asin(Δω/K)/M — about 1.6° here at full coupling. A real phase-locked
* loop holds exactly that offset, and pretending otherwise would need
* the very tween this file avoids.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `sync`, `markers`, `size`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SyncOrbitProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Coupling strength, 0 (free-running) to 1 (firmly locked). */
sync?: number;
/** Points on each ring. Both rings carry the same number. */
markers?: number;
/** Drawing box in px. */
size?: number;
/** Point colour. Defaults to the inherited text colour. */
color?: string;
};
type VariantConfig = {
/** Turns per second of the outer ring. */
spin: number;
/** Scales the rate difference and the coupling together, so the lock
* threshold sits at the same `sync` value in every variant. */
rate: number;
/** Point radius in px at the default size. */
dot: number;
/** Inner ring radius as a fraction of the outer. */
inner: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A slow drift and a long, patient beat.
subtle: { spin: 0.05, rate: 0.7, dot: 1.9, inner: 0.68 },
// Slips visibly, locks in a couple of seconds. All-purpose.
default: { spin: 0.075, rate: 1, dot: 2.3, inner: 0.62 },
// Quicker turn, wider gap between the rings, a brisker catch.
playful: { spin: 0.11, rate: 1.4, dot: 2.7, inner: 0.54 },
};
/** Free-running rate difference between the rings, rad/s at rate 1. */
const MISMATCH = 0.3;
/** Coupling at sync = 1, rad/s at rate 1. Lock needs K ≥ MISMATCH. */
const COUPLING = 0.9;
export default function SyncOrbit({
variant = "default",
sync = 0.55,
markers = 12,
size = 148,
color,
}: SyncOrbitProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Read through a ref: raising the coupling must not restart the rings,
// because their accumulated phase difference is the whole state. The
// ref is written in an effect, never during render.
const syncRef = useRef(sync);
useEffect(() => {
syncRef.current = sync;
}, [sync]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const count = Math.max(4, Math.round(markers));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(size * ratio));
canvas.height = Math.max(1, Math.floor(size * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
const centre = size / 2;
const outerRadius = size * 0.38;
const innerRadius = outerRadius * config.inner;
const slot = (Math.PI * 2) / count;
const dot = config.dot * (size / 148);
let outerAngle = 0;
/** The only state that matters: how far the inner ring lags. */
let phase = slot * 0.5;
const render = () => {
context.clearRect(0, 0, size, size);
const innerAngle = outerAngle + phase;
// Folded into one marker slot: this is what the eye actually reads
// as "in step", and it is the same number the coupling acts on.
let offset = ((phase % slot) + slot) % slot;
if (offset > slot / 2) offset -= slot;
const alignment = 1 - Math.abs(offset) / (slot / 2);
context.strokeStyle = ink;
context.lineWidth = 1;
for (const radius of [outerRadius, innerRadius]) {
context.globalAlpha = 0.1;
context.beginPath();
context.arc(centre, centre, radius, 0, Math.PI * 2);
context.stroke();
}
// Spokes appear whenever the two rings are close to in step — so
// they flash past during the beat and hold once it locks. They are
// a reading of the phase, not a separate animation.
const shown = Math.max(0, (alignment - 0.62) / 0.38);
if (shown > 0.01) {
context.lineWidth = 1;
context.globalAlpha = shown * 0.34;
context.beginPath();
for (let index = 0; index < count; index++) {
const angle = outerAngle + index * slot;
context.moveTo(
centre + Math.cos(angle) * innerRadius,
centre + Math.sin(angle) * innerRadius
);
context.lineTo(
centre + Math.cos(angle) * outerRadius,
centre + Math.sin(angle) * outerRadius
);
}
context.stroke();
}
context.fillStyle = ink;
for (let index = 0; index < count; index++) {
const lead = index === 0;
const angleOuter = outerAngle + index * slot;
context.globalAlpha = (lead ? 0.85 : 0.42) + shown * 0.3;
context.beginPath();
context.arc(
centre + Math.cos(angleOuter) * outerRadius,
centre + Math.sin(angleOuter) * outerRadius,
dot * (lead ? 1.55 : 1),
0,
Math.PI * 2
);
context.fill();
const angleInner = innerAngle + index * slot;
context.globalAlpha = (lead ? 0.85 : 0.42) + shown * 0.3;
context.beginPath();
context.arc(
centre + Math.cos(angleInner) * innerRadius,
centre + Math.sin(angleInner) * innerRadius,
dot * (lead ? 1.55 : 0.86),
0,
Math.PI * 2
);
context.fill();
}
context.globalAlpha = 0.18 + shown * 0.45;
context.beginPath();
context.arc(centre, centre, dot * 0.9, 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
};
// Reduced motion: the rings held at the phase the coupling would
// settle them at. Above the threshold that is the standing error,
// asin(Δω/K)/M, and the spokes are there; below it there is no
// settled phase at all, so they sit apart and the spokes are not.
// Whether the two are in step is the reading, and a still frame
// carries it.
if (reduced) {
const still = () => {
const value = Math.min(1, Math.max(0, syncRef.current));
const coupling = COUPLING * config.rate * value;
const mismatch = MISMATCH * config.rate;
phase =
coupling >= mismatch
? Math.asin(mismatch / coupling) / count
: slot * 0.42;
render();
return value;
};
let shownStill = still();
const poll = window.setInterval(() => {
if (Math.abs(syncRef.current - shownStill) > 0.01) shownStill = still();
}, 250);
return () => window.clearInterval(poll);
}
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
const value = Math.min(1, Math.max(0, syncRef.current));
const coupling = COUPLING * config.rate * value;
const mismatch = MISMATCH * config.rate;
outerAngle += config.spin * Math.PI * 2 * delta;
if (outerAngle > Math.PI * 2) outerAngle -= Math.PI * 2;
// The whole model. Lock is where the second term can cancel the
// first, which is a fact about the numbers, not a keyframe.
phase += (mismatch - coupling * Math.sin(count * phase)) * delta;
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [variant, markers, size, color]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label={sync < 0.34 ? "Replicas drifting" : sync < 0.7 ? "Replicas converging" : "Replicas in sync"}
style={{ width: size, height: size, display: "block" }}
/>
);
}About this effect
A readout for two things that are meant to agree — replicas, a device and its backup, a local cache against the server. Nothing tweens the rates together: the inner ring's rate is nudged by how far out of step it currently is, so lock is something the pair does rather than something the animation announces, and it happens exactly when the coupling can cover the rate difference and not before. That threshold is what buys the good part — just under it the slip collapses at the alignment and races between them, a twelfth of its peak in simulation, so the rings visibly hang together, give up and hang again. That beat is what two nearly-synchronised things actually look like, and it costs nothing extra because it is the same equation rather than a second animation. Locked is not identical either: the pair settles at whatever standing phase error it needs to make up the rate difference, about a degree and a half at full coupling, which is what a real phase-locked loop does.