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 spins one ring, positions every child at a fixed
angle on that ring, and spins each child's own content backward by the same
amount. No child is animated individually.
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 only 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 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
inverse on the same clock.
With the same duration, ease: "linear", and 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. They are 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 composites
already-painted layers along a fixed circle and recalculates no positions.
The result
One ring rotation and one exact inverse make the whole orbit, with every icon 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 spins one ring, positions every child at a fixed
angle on that ring, and spins each child's own content backward by the same
amount. No child is animated individually.
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 only 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, so 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 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
inverse on the same clock.
With the same duration, ease: "linear", and 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. They are 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 composites
already-painted layers along a fixed circle and recalculates no positions.
The result
One ring rotation and one exact inverse make the whole orbit, with every icon upright the entire way around.
Accessibility
Under prefers-reduced-motion, both animate props are dropped (no ring
spin, no counter-spin). Each slot's counter-rotation is set once via a
static style.transform: rotate(-angle), so icons still land upright and
stay 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