Items that orbit a center on circular tracks while staying upright. Evenly spaced, any radius, speed and direction; stack several for rings. Paused off-screen, static under reduced motion.
import * as React from "react"
import {
Bell,
Calendar,
Cloud,
Database,
FileText,
Image,
Layers,
Mail,
MessageSquare,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { OrbitingCircles } from "@/components/ballmac/orbiting-circles"
function Tile({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div
title={label}
className={cn(
"flex size-full items-center justify-center rounded-full border bg-card shadow-[0_1px_2px_0_rgb(0_0_0/0.06),0_6px_16px_-6px_rgb(0_0_0/0.18)] [&_svg]:size-[45%]",
className
)}
>
{children}
<span className="sr-only">{label}</span>
</div>
)
}
export default function OrbitingCirclesDemo() {
return (
<div className="relative flex h-[360px] w-full max-w-lg items-center justify-center overflow-hidden">
<div
aria-hidden="true"
className="pointer-events-none absolute size-72 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--chart-1)_16%,transparent),transparent)]"
/>
<div className="relative z-10 flex size-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.16),0_10px_30px_-8px_color-mix(in_oklch,var(--chart-1)_50%,transparent)]">
<Layers className="size-7" aria-hidden="true" />
<span className="sr-only">Acme</span>
</div>
<OrbitingCircles radius={78} duration={18} iconSize={34}>
<Tile label="Mail"><Mail className="text-chart-1" /></Tile>
<Tile label="Calendar"><Calendar className="text-chart-5" /></Tile>
<Tile label="Messages"><MessageSquare className="text-chart-2" /></Tile>
</OrbitingCircles>
<OrbitingCircles radius={148} duration={32} iconSize={42} reverse startAngle={-60}>
<Tile label="Database"><Database className="text-chart-4" /></Tile>
<Tile label="Files"><FileText className="text-chart-1" /></Tile>
<Tile label="Cloud"><Cloud className="text-chart-2" /></Tile>
<Tile label="Photos"><Image className="text-chart-3" /></Tile>
<Tile label="Alerts"><Bell className="text-chart-5" /></Tile>
</OrbitingCircles>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/orbiting-circlesCopy the source into your project.
components/ballmac/orbiting-circles.tsx// Ballmac UI: Orbiting Circles. https://ui.ballmac.com/components/orbiting-circles "use client" import * as React from "react" import { cn } from "@/lib/utils" type OrbitingCirclesProps = React.ComponentProps<"div"> & { /** Radius of the orbit in pixels. */ radius?: number /** Seconds for one full orbit. */ duration?: number /** Orbit counter-clockwise. */ reverse?: boolean /** Draw the circular track the items travel on. */ path?: boolean /** Width and height of each orbiting item in pixels. */ iconSize?: number /** Angle in degrees where the first item starts; the rest are spaced evenly. */ startAngle?: number } function OrbitingCircles({ radius = 120, duration = 24, reverse = false, path = true, iconSize = 40, startAngle = -90, className, children, ...props }: OrbitingCirclesProps) { const rootRef = React.useRef<HTMLDivElement>(null) const items = React.Children.toArray(children) const count = items.length React.useEffect(() => { const root = rootRef.current if (!root || typeof root.animate !== "function") return if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return const turn = reverse ? -360 : 360 const animations = Array.from(root.querySelectorAll<HTMLElement>("[data-slot=orbiting-circles-item]")).map( (node, i) => { const angle = startAngle + (360 / Math.max(count, 1)) * i // Rotate out, translate to the rim, rotate back: the item orbits but stays upright. return node.animate( [ { transform: `rotate(${angle}deg) translateX(${radius}px) rotate(${-angle}deg)` }, { transform: `rotate(${angle + turn}deg) translateX(${radius}px) rotate(${-angle - turn}deg)` }, ], { duration: duration * 1000, iterations: Infinity, easing: "linear" } ) } ) let visible = true const sync = () => { for (const a of animations) { if (visible && !document.hidden) a.play() else a.pause() } } const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true sync() }) io.observe(root) document.addEventListener("visibilitychange", sync) return () => { io.disconnect() document.removeEventListener("visibilitychange", sync) for (const a of animations) a.cancel() } }, [radius, duration, reverse, startAngle, count]) return ( <div ref={rootRef} data-slot="orbiting-circles" className={cn("pointer-events-none absolute inset-0 flex items-center justify-center", className)} {...props} > {path && ( <svg aria-hidden="true" data-slot="orbiting-circles-path" className="absolute overflow-visible" width={radius * 2} height={radius * 2} > <circle cx={radius} cy={radius} r={radius} fill="none" strokeWidth={1} className="stroke-foreground/10" strokeDasharray="2 5" /> </svg> )} {items.map((child, i) => { const angle = startAngle + (360 / Math.max(count, 1)) * i return ( <div key={i} data-slot="orbiting-circles-item" className="pointer-events-auto absolute flex items-center justify-center" style={{ width: iconSize, height: iconSize, // Static position for the server render and reduced motion; the animation takes over after mount. transform: `rotate(${angle}deg) translateX(${radius}px) rotate(${-angle}deg)`, }} > {child} </div> ) })} </div> ) } export { OrbitingCircles, type OrbitingCirclesProps }Update the import paths to match your project setup.
Usage
import { OrbitingCircles } from "@/components/ballmac/orbiting-circles"The full example is in the Code tab above.
Examples
Single ring
import { OrbitingCircles } from "@/components/ballmac/orbiting-circles"
export default function OrbitingCirclesMinimal() {
return (
<div className="relative flex h-64 w-full max-w-sm items-center justify-center">
<span className="font-mono text-xs tracking-widest text-muted-foreground uppercase">in sync</span>
<OrbitingCircles radius={96} duration={12} iconSize={10}>
<span className="size-2.5 rounded-full bg-chart-1" />
<span className="size-2.5 rounded-full bg-chart-2" />
<span className="size-2.5 rounded-full bg-chart-4" />
</OrbitingCircles>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
radiusRadius of the orbit in pixels. | number | 120 |
durationSeconds for one full orbit. | number | 24 |
reverseOrbit counter-clockwise. | boolean | false |
pathDraw the circular track the items travel on. | boolean | true |
iconSizeWidth and height of each orbiting item in pixels. | number | 40 |
startAngleAngle in degrees where the first item starts; the rest are spaced evenly. | number | -90 |
Also accepts the standard attributes of its root element.
Use with AI
Inside a relative container with a fixed height, render the center element, then one <OrbitingCircles radius duration> per ring with the orbiting items as children. Each ring fills the container (absolute inset-0) and centers itself; items are laid out evenly around the circle. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Orbiting Circles (@ballmac/orbiting-circles) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An integrations or ecosystem section: your product in the middle, tools around it
- A hero visual for a platform that connects many services
Not for
- Items people must read or click reliably (moving targets); use a grid
- More than about 8 items per ring
Registry JSON: https://ui.ballmac.com/r/orbiting-circles.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- shadcn/utils
Pairs well with
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Dot Pattern
A lightweight SVG dot grid background with an optional radial fade and a few softly pulsing glow dots. One pattern element, token colors, hydration-safe, still under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.
Animated List
A live feed where new items spring in at the top and the rest glide down, with leave animations, an item cap, an optional fading tail and a polite announcement for new entries.