Anatomy of Orbiting Circles
How one rotating ring places every child on a circle, and the one counter-rotation that keeps them from tumbling as they travel.
↓Scroll to step through it
The box, the ring, and one angle per slot
OrbitingCircles doesn't animate each child individually — it spins one
ring, positions every child at a fixed angle on that ring, and then spins
each child's own content backward by the exact same amount. Three simple
transforms, layered.
The component's outer box is sized from its own geometry —
radius * 2 + iconSize — so it never clips the orbit. Every child gets an
even slice of the circle, angle = (360 / n) * i, and is placed with a
single rotate → translate pair: rotate to the child's angle, then push it
out along the now-rotated Y axis.
The outer motion.div — the ring — renders no visible markup of its own.
It exists purely to rotate its children together; showPath is a separate,
static circle drawn underneath for the faint track.
Counter-rotation
The ring has to rotate as a whole — that's what makes every slot travel together in lockstep instead of each running its own orbit math. But rotating the ring also rotates everything inside it, including each slot's icon. Left alone, an icon would spin a full 360° every orbit — upside-down at the bottom, sideways at the sides.
The fix is a second motion.div inside each slot, animating the exact
inverse on the identical clock.
Same duration, same ease: "linear", same repeat: Infinity — the two
rotations are mirror images of each other, so their sum is always zero.
reverse flips both signs together, which is why it never breaks the
cancellation.
Two rings, sharing nothing but a center
radius, duration, and reverse are all per-instance, so stacking two
OrbitingCircles on the same absolutely-positioned center gives you two
independently-timed orbits with no shared state — just two components that
happen to overlap.
Both rotations — the ring's and each slot's counter-rotation — are
rotate/translateY on transform, computed once from radius and
iconSize and never touched again after mount. A dense orbit of a dozen
icons costs the same per frame as one: the browser is just compositing
already-painted layers along a fixed circle, not recalculating positions.
The result
One ring rotation, one exact inverse — that's the whole orbit, upright the entire way around.
- Path
- showPath — a faint static circle at radius*2
- Ring
- invisible motion.div, rotates 360° and carries every slot
- Slot
- rotate(angle) translateY(−radius), one per child
const box = radius * 2 + iconSize;const angle = (360 / n) * i;<divstyle={{ width: iconSize, height: iconSize, transform: `rotate(${angle}deg) translateY(-${radius}px)`,}}>{child}</div>Anatomy of Orbiting Circles
How one rotating ring places every child on a circle, and the one counter-rotation that keeps them from tumbling as they travel.
- Path
- showPath — a faint static circle at radius*2
- Ring
- invisible motion.div, rotates 360° and carries every slot
- Slot
- rotate(angle) translateY(−radius), one per child
const box = radius * 2 + iconSize;const angle = (360 / n) * i;<divstyle={{ width: iconSize, height: iconSize, transform: `rotate(${angle}deg) translateY(-${radius}px)`,}}>{child}</div>The box, the ring, and one angle per slot
OrbitingCircles doesn't animate each child individually — it spins one
ring, positions every child at a fixed angle on that ring, and then spins
each child's own content backward by the exact same amount. Three simple
transforms, layered.
The component's outer box is sized from its own geometry —
radius * 2 + iconSize — so it never clips the orbit. Every child gets an
even slice of the circle, angle = (360 / n) * i, and is placed with a
single rotate → translate pair: rotate to the child's angle, then push it
out along the now-rotated Y axis.
The outer motion.div — the ring — renders no visible markup of its own.
It exists purely to rotate its children together; showPath is a separate,
static circle drawn underneath for the faint track.
without — tumbles
with — stays upright
- Without
- no inner transform — content tumbles with the ring
- With
- rotate([-angle, -angle-360]) cancels the ring exactly
<motion.divanimate={{ rotate: reverse ? -360 : 360 }} // the ringtransition={spin}>{/* … */}<motion.div animate={{ rotate: reverse ? [-angle, -angle + 360] : [-angle, -angle - 360] }} transition={spin} // same duration, same ease> {child}</motion.div></motion.div>Counter-rotation
The ring has to rotate as a whole — that's what makes every slot travel together in lockstep instead of each running its own orbit math. But rotating the ring also rotates everything inside it, including each slot's icon. Left alone, an icon would spin a full 360° every orbit — upside-down at the bottom, sideways at the sides.
The fix is a second motion.div inside each slot, animating the exact
inverse on the identical clock.
Same duration, same ease: "linear", same repeat: Infinity — the two
rotations are mirror images of each other, so their sum is always zero.
reverse flips both signs together, which is why it never breaks the
cancellation.
- Inner ring
- radius 52 · 4.8s · forward
- Outer ring
- radius 94 · 8.4s · reverse
- Center
- shared anchor — each ring is its own instance
<div className="relative flex h-80 items-center justify-center"><OrbitingCircles radius={60} duration={14} className="absolute"> <Icon /><Icon /></OrbitingCircles><OrbitingCircles radius={120} duration={24} reverse className="absolute"> <Icon /><Icon /><Icon /></OrbitingCircles></div>Two rings, sharing nothing but a center
radius, duration, and reverse are all per-instance, so stacking two
OrbitingCircles on the same absolutely-positioned center gives you two
independently-timed orbits with no shared state — just two components that
happen to overlap.
Both rotations — the ring's and each slot's counter-rotation — are
rotate/translateY on transform, computed once from radius and
iconSize and never touched again after mount. A dense orbit of a dozen
icons costs the same per frame as one: the browser is just compositing
already-painted layers along a fixed circle, not recalculating positions.
The result
One ring rotation, one exact inverse — that's the whole orbit, upright the entire way around.
Accessibility
Under prefers-reduced-motion, both animate props are dropped entirely —
no ring spin, no counter-spin — and each slot's counter-rotation is set
once via a static style.transform: rotate(-angle) so icons still land
upright, just frozen in place instead of orbiting.
animate={reduceMotion ? undefined : { rotate: reverse ? -360 : 360 }}
style={reduceMotion ? { transform: `rotate(${-angle}deg)` } : undefined}Motion Score
translatePosition / lift via translaterotateRotation