Auto-orient
How to build auto-orient
Flat circles travel around a tilted ring — the near ones swell and slow, the far ones shrink and speed up. Scale one perspective factor into both position and size, and the pacing and the depth both fall out of that single projection, kept in step by the geometry. When you want depth while keeping a flat, graphic look, this is how to build it — with code.
- Published
- June 14, 2026
- Topics
- Auto-orient · 3D · Perspective · SVG
Building a tilted ring and a two-axis spin
- You hand-place eight equal circles — a light tone and a dark tone, every other one
- The circles don't sit on a flat disc; they ride a 3D ring tilted a little off the screen
- The ring turns about the screen's axis, and the circles also turn about the ring's own facing — that double turn is the whole motion
// fold the frame number into a position inside the loop
const wrap = (frame, span) => ((frame % span) + span) % span;
// once up front: the tilted ring normal, and how fast the parent and child turn
const n0 = tiltedUnit(axis, tiltDeg, tiltAzimuthDeg);
const parentPerFrame = 360 * parentTurns / periodFrames;
const spinPerFrame = 360 * spinTurns / periodFrames;tiltedUnit tilts the ring's face off a straight axis and returns the direction square to that face (its normal). ringPoints3d places count points evenly around a circle centred on that normal. rotationAboutAxis and applyMat3 turn a point about any axis you like — the axis rotation is the standard Rodrigues formula, written with just sine and cosine. They're all small parts built from vector adds and multiplies, and once you make n0 you reuse it every frame.
// frame number → each dot's position and size on screen
const dotsAt = (frame) => {
const t = wrap(frame, periodFrames);
// child: dots circle around the ring's own facing
const ring = ringPoints3d(n0, spinPhase0 + spinPerFrame * t, ringRadius, count);
// parent: the whole ring turns about the screen axis
const rot = rotationAboutAxis(axis, parentPerFrame * t);
return ring.map((p, i) => {
const spun = applyMat3(rot, p);
const z = spun[2] / ringRadius;
const m = 1 + depthK * z; // one perspective factor, the same for position and size
const dark = i % 2 === 0;
return {
cx: center[0] + spun[0] * m,
cy: center[1] + spun[1] * m,
r: (dark ? darkRadius : lightRadius) * m,
z,
dark,
};
});
};Here's what it really is. What separates front from back is z, each point's depth, and depthK turns that into a factor m. The same m scales both the position out from the centre and the circle's size — so a front circle reads large and outward, a back one small and inward. The apparent slow-and-fast, the vertical bob, the front/back swap all arise on their own from this perspective. Nowhere is there a keyframe of easing.
To put it on screen, draw the back circles first — the front ones then overlap on top, so nearer reads as in front. After that it's one circle each. Rather than hard-coding a colour, it takes the page's own ink and just dims the lighter circles so the two tones read. 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.
To play with it, change the tilt tiltDeg and its direction tiltAzimuthDeg; the ring lies back differently and the front/back gap grows or shrinks. Swap spinTurns and parentTurns to change which way and how fast it turns, and raise depthK to make the perspective harsher so front circles jump forward. Swap the circles for squares and the same idea — place count of them in 3D and spin — keeps working.