A circular orbit component that displays elements around a center and rotates them continuously, ideal for galleries, dashboards, and navigation menus.
import CircularList from "@/registry/components/circular-list/circular-list";
const CircularListPreview = () => {
return (
<div className="relative">
<CircularList
className="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]"
radius={75}
>
{/*
Add any element here which you want to show on circumference.
Link, Button, Icon etc .
*/}
<MetaIcon color="#0866FF" />
<YoutubeIcon color="#FF0000" />
<DribbleIcon color="#EA4C89" />
</CircularList>
<CircularList
radius={150}
degreeOffset={45}
direction="anti-clockwise"
className="absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]"
>
<MetaIcon color="#0866FF" />
<YoutubeIcon color="#FF0000" />
<RdioIcon color="#007DC5" />
<DribbleIcon color="#EA4C89" />
<PintrestIcon color="#E60023" />
</CircularList>
</div>
);
};
const iconStyle = {
width: "32px",
height: "32px",
background: "#ffffff",
borderRadius: "50%",
};
type IconProps = { color: string; };
const YoutubeIcon = ({ color } : IconProps) => (
<svg style={{ ...iconStyle, fill: color }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M11.603 9.833L9.357 8.785C9.161 8.694 9 8.796 9 9.013v1.974c0 .217.161.319.357.228l2.245-1.048c.197-.092.197-.242.001-.334M10 .4C4.698.4.4 4.698.4 10s4.298 9.6 9.6 9.6s9.6-4.298 9.6-9.6S15.302.4 10 .4m0 13.5c-4.914 0-5-.443-5-3.9s.086-3.9 5-3.9s5 .443 5 3.9s-.086 3.9-5 3.9"/>
</svg>
);
const MetaIcon = ({ color } : IconProps) => (
<svg style={{ ...iconStyle, fill: color }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M8.349 9.257a2.6 2.6 0 0 0-.497-.293a1.3 1.3 0 0 0-.519-.117a.9.9 0 0 0-.757.361a1.3 1.3 0 0 0-.281.812q0 .491.287.805c.287.314.455.314.791.314q.259 0 .519-.104q.26-.102.491-.259q.233-.156.437-.354q.205-.198.368-.389a12 12 0 0 0-.382-.387a5 5 0 0 0-.457-.389m4.278-.41a1.2 1.2 0 0 0-.525.117a2.3 2.3 0 0 0-.478.293q-.225.177-.43.389q-.207.212-.368.389q.177.206.382.402q.204.198.438.355q.23.156.483.252t.539.096q.505 0 .777-.328a1.2 1.2 0 0 0 .272-.805q-.001-.478-.293-.818q-.293-.342-.797-.342M10 .4C4.698.4.4 4.698.4 10s4.298 9.6 9.6 9.6s9.6-4.298 9.6-9.6S15.302.4 10 .4m4.835 10.562q-.163.464-.463.811q-.3.349-.743.546a2.4 2.4 0 0 1-.989.197q-.423 0-.791-.129a3 3 0 0 1-.689-.342a4 4 0 0 1-.608-.49q-.285-.281-.546-.58q-.286.3-.559.58a4 4 0 0 1-.581.49a3 3 0 0 1-.668.342q-.36.129-.812.129a2.4 2.4 0 0 1-.996-.197a2.3 2.3 0 0 1-.75-.532a2.3 2.3 0 0 1-.478-.798A3 3 0 0 1 5 9.994q0-.532.157-.989q.158-.457.457-.792q.3-.334.737-.532a2.4 2.4 0 0 1 .982-.197q.45 0 .825.137q.374.135.695.361q.322.224.602.518c.28.294.37.402.552.621q.261-.314.539-.613q.28-.301.602-.525q.32-.226.695-.361q.376-.138.81-.137q.547-.001.984.191q.436.19.736.524t.463.784q.165.45.164.982q0 .534-.165.996"/>
</svg>
);
const RdioIcon = ({ color } : IconProps) => (
<svg style={{ ...iconStyle, fill: color }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 .4C4.698.4.4 4.698.4 10s4.298 9.6 9.6 9.6s9.6-4.298 9.6-9.6S15.302.4 10 .4m3.403 9.082q.034.255.034.518c0 2.176-1.742 3.941-3.892 3.941c-2.148 0-3.891-1.766-3.891-3.941c0-2.178 1.742-3.942 3.891-3.942c.309 0 .608.039.896.107V8.41c-.454-.166-1.015-.142-1.541.111c-.952.461-1.435 1.494-1.079 2.311c.357.816 1.418 1.106 2.371.645c.656-.316 1.234-1.078 1.234-2.035V6.549q.123.07.24.146c.739.465 1.838 1.086 3.121 1.152c.501.026-.197 1.284-1.384 1.635"/>
</svg>
);
const DribbleIcon = ({ color } : IconProps) => (
<svg style={{ ...iconStyle, fill: color }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10.26 9.982q.05-.017.103-.031a15 15 0 0 0-.279-.584c-1.88.557-3.68.562-4.001.557q-.004.038-.003.076c0 .945.34 1.853.958 2.566c.206-.332 1.298-1.961 3.222-2.584m-2.637 3.131a3.91 3.91 0 0 0 3.871.512a16.5 16.5 0 0 0-.822-2.922c-2.121.75-2.922 2.162-3.049 2.41m4.932-6.086a3.92 3.92 0 0 0-3.405-.853a20 20 0 0 1 1.421 2.223c1.283-.493 1.863-1.204 1.984-1.37m-2.85 1.637A24 24 0 0 0 8.29 6.473a3.94 3.94 0 0 0-2.113 2.658h.017c.406 0 1.849-.033 3.511-.467m1.809 1.832c.465 1.293.679 2.367.74 2.711a3.93 3.93 0 0 0 1.609-2.543a5.8 5.8 0 0 0-1.592-.221q-.389 0-.757.053M10 .4C4.698.4.4 4.698.4 10s4.298 9.6 9.6 9.6s9.6-4.298 9.6-9.6S15.302.4 10 .4m0 14.297A4.703 4.703 0 0 1 5.301 10A4.703 4.703 0 0 1 10 5.301A4.704 4.704 0 0 1 14.698 10A4.7 4.7 0 0 1 10 14.697m.922-5.623q.13.27.242.531l.071.17q.417-.05.882-.049a9.7 9.7 0 0 1 1.801.172a3.93 3.93 0 0 0-.852-2.34c-.16.206-.818.963-2.144 1.516"/>
</svg>
);
const PintrestIcon = ({ color } : IconProps) => (
<svg style={{ ...iconStyle, fill: color }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 .4C4.698.4.4 4.698.4 10s4.298 9.6 9.6 9.6s9.6-4.298 9.6-9.6S15.302.4 10 .4m.657 11.875c-.616-.047-.874-.352-1.356-.644c-.265 1.391-.589 2.725-1.549 3.422c-.297-2.104.434-3.682.774-5.359c-.579-.975.069-2.936 1.291-2.454c1.503.596-1.302 3.625.581 4.004c1.966.394 2.769-3.412 1.55-4.648c-1.762-1.787-5.127-.041-4.713 2.517c.1.625.747.815.258 1.678c-1.127-.25-1.464-1.139-1.42-2.324c.069-1.94 1.743-3.299 3.421-3.486c2.123-.236 4.115.779 4.391 2.777c.309 2.254-.959 4.693-3.228 4.517"/>
</svg>
);
export default CircularListPreview; Install the following packages before using this component.
import { Children, memo } from "react";
import { motion } from "motion/react";
export type CircularListProps = {
radius?: number;
duration?: number;
offsetDegree?: number;
rotationLock?: boolean;
direction?: "clockwise" | "anti-clockwise";
children?: React.PropsWithChildren;
className?: string;
style?: React.CSSProperties;
} & React.ComponentProps<"div">;
const CircularList = (
props: CircularListProps,
) => {
const {
radius = 100,
duration = 10,
offsetDegree = 0,
rotationLock = true,
direction = "clockwise",
children,
className,
style,
...restProps
} = props;
const childrenCount = Children.count(children);
const _radius = Math.max(0, radius);
const _duration = Math.min(Math.max(1, duration), 10 * 60);
const getCoordinates = (
angle: number,
radius: number,
) => {
const radians = +((Math.PI / 180) * angle).toPrecision(4);
return {
x: +((Math.cos(radians) * radius).toFixed(0)),
y: +((Math.sin(radians) * radius).toFixed(0)),
};
};
const variants = {
start: {
rotate: 0,
},
end: {
rotate: 360,
}
};
const isClockwise = direction === "clockwise";
return (
<div
{...restProps}
className={className}
style={{
...style,
width: `${_radius * 2}px`,
height: `${_radius * 2}px`,
}}
>
<motion.div
className="relative w-full h-full border border-neutral-200 dark:border-neutral-700/50 rounded-[50%]"
variants={variants}
animate={isClockwise ? "start" : "end"}
style={variants[isClockwise ? "end" : "start"]}
transition={{
ease: "linear",
duration: _duration,
repeat: Infinity,
}}
>
{Children.map(children, (item, index) => {
const angle = (Math.abs(offsetDegree) + (360 / childrenCount * index)) % 360;
const { x, y } = getCoordinates(angle, _radius);
return (
<motion.span
key={`circular-list-item-${index}`}
className="absolute top-[50%] left-[50%]"
animate={{
transform: `
translate(calc(-50% + ${x}px), calc(-50% + ${y}px))
rotate(${rotationLock ? (isClockwise ? 360 : 0) : angle}deg)
`,
}}
style={{
transform: `
translate(calc(-50% + ${x}px), calc(-50% + ${y}px))
rotate(${rotationLock ? (isClockwise ? 0 : 360) : angle}deg)
`,
}}
transition={{
ease: "linear",
duration: _duration,
repeat: Infinity,
}}
>
{item}
</motion.span>
);
})}
</motion.div>
</div>
);
};
export default memo(CircularList); | Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | ReactNode | Yes | — | Elements that will be arranged along the circle’s circumference. |
| radius | number | No | 100 | Radius of the circle in pixels (px) used to position the children around the center. |
| duration | number | No | 10 | Time (in seconds) it takes to complete one full 360° rotation. |
| rotationLock | boolean | No | true | Prevents orbiting items from rotating with the path, keeping them upright. |
| direction | "clockwise" | "anti-clockwise" | No | "clockwise" | Controls the direction of rotation. |
| degreeOffset | number | No | 0 | Starting angle offset (in degrees) from which the circular layout begins. |
| pauseOnHover | boolean | No | false | Pauses the rotation animation when the user hovers over the component. |
| className | string | No | — | Additional CSS class names applied to the root container. |
| style | React.CSSProperties | No | — | Inline styles applied to the root container. |
If you find this component useful, consider starring the repository on GitHub. Found a bug or have a suggestion? Open an issue to help improve it.