Dirck Mulder
Blocks||3 min read

Build Circling Elements in React

Create a set of elements that orbit a center point with configurable speed, radius, and stagger using CSS animation and Framer Motion.

Orbiting elements are a great way to show relationships visually. Tech stacks circling a logo, team avatars around a company mark, feature icons surrounding a product image. CirclingElements gives you a reusable orbital system where you supply the children and it handles the distribution and spin.

The final result

What we are building

A container component that places any number of child elements in a circular orbit, evenly distributed by angle. Each element uses a CSS animation with custom properties to drive its position. The whole system accepts a direction, speed, and optional hover pause.

Setting up

bash
npm install motion
tsx
import { Children } from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';

Building the component

The component accepts an easing, direction, radius, and duration, plus children and a pauseOnHover flag:

tsx
type CirclingElementsProps = {
  children: React.ReactNode;
  radius?: number;
  duration?: number;
  easing?: string;
  direction?: 'normal' | 'reverse';
  className?: string;
  pauseOnHover?: boolean;
};

Inside the render, Children.count gives the total number of children so each element can be assigned an evenly spaced angle offset:

tsx
{Children.map(children, (child, index) => {
  const offset = (index * 360) / Children.count(children);

  const animationProps = {
    '--circling-duration': duration,
    '--circling-radius': radius,
    '--circling-offset': offset,
    '--circling-direction': direction === 'reverse' ? -1 : 1,
    animation: `circling ${duration}s ${easing} infinite`,
    animationName: 'circling',
    animationDuration: `${duration}s`,
    animationTimingFunction: easing,
    animationIterationCount: 'infinite',
  } as React.CSSProperties;

The CSS custom properties (--circling-offset, --circling-radius, etc.) are read by the animate-circling Tailwind keyframe animation. That animation uses @property registered variables so each element can have its own starting angle without needing JavaScript to calculate positions per-frame.

Each element gets wrapped in a motion.div with those animation styles applied:

tsx
return (
    <motion.div
      key={index}
      style={animationProps}
      className={cn(
        'transform-gpu animate-circling absolute -translate-x-1/2 -translate-y-1/2',
        pauseOnHover &&
          'group-hover/circling:![animation-play-state:paused]'
      )}
    >
      {child}
    </motion.div>
  );
})}

The parent container uses the group/circling Tailwind variant class. When pauseOnHover is true, hovering the container triggers [animation-play-state:paused] on all children simultaneously via the group selector. The ! prefix forces the override even when the animation was set inline.

The container itself is just a relative-positioned div:

tsx
return (
  <div className={cn('relative z-0 group/circling', className)}>
    {Children.map(children, ...)}
  </div>
);

Place whatever you want at the center of the orbit as a sibling element, absolutely positioned with inset-0 m-auto.

How to use it

tsx
<div className="relative w-64 h-64">
  {/* Center element */}
  <div className="absolute inset-0 flex items-center justify-center">
    <img src="/logo.svg" className="w-12 h-12" />
  </div>

  <CirclingElements radius={100} duration={10} direction="normal" pauseOnHover>
    <img src="/icon-1.svg" className="w-8 h-8" />
    <img src="/icon-2.svg" className="w-8 h-8" />
    <img src="/icon-3.svg" className="w-8 h-8" />
    <img src="/icon-4.svg" className="w-8 h-8" />
  </CirclingElements>
</div>

Key takeaways

  • Dividing 360 degrees by the child count produces a perfectly even angular distribution without any special case for different numbers of children.
  • CSS custom properties per element let you drive per-element starting positions from a single shared keyframe animation rather than generating unique keyframes for each index.
  • The group hover variant group-hover/circling:![animation-play-state:paused] pauses all children simultaneously with one CSS rule.