Orbiting Circles
The children orbit the centre of their parent and stay upright. One transform animation per item (rotate, translate out, rotate back) from its start angle to a full turn, on the compositor, no wrapper. Start angles come from :nth-child, the count from :has(). Two instances at two radii are two rings; `reverse` sends one the other way. Zero JS.
Live demo
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 6.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: pass `count`; without :has() and without count the items stack at one angle.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import OrbitingCircles from '@moonarc/core/OrbitingCircles';
---
<div class="relative h-96">
<OrbitingCircles radius={110} duration={24}>
<Icon name="astro" />
<Icon name="tailwind" />
<Icon name="vite" />
<Icon name="typescript" />
</OrbitingCircles>
<OrbitingCircles radius={60} duration={16} reverse size={32}>
<Icon name="npm" />
<Icon name="pnpm" />
</OrbitingCircles>
</div>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/orbiting-circles.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* OrbitingCircles — the children orbit the centre of their parent and stay
* upright. One animation per item: `rotate(a) translate(r) rotate(−a)` from
* a to a + 360°, where a is the item's start angle from its index — so the
* icon turns around the centre without turning itself, on the compositor,
* with no wrapper. The start angles come from :nth-child, the count from
* :has(), so nothing is passed twice; `count` overrides when the children
* are not the whole ring. Two instances at two radii are two rings;
* `reverse` sends one the other way. Fills its positioned parent.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Orbit radius in px. */
radius?: number;
/** Seconds per revolution. */
duration?: number;
/** Run counter-clockwise. */
reverse?: boolean;
/** Draw the orbit line. */
path?: boolean;
/** Number of items on the ring; default counted from the children (up to 12). */
count?: number;
/** Item size in px (each child is a square cell of this size). */
size?: number;
/** Orbit line colour. */
color?: string;
}
const { radius = 100, duration = 20, reverse = false, path = true, count, size = 40, color = 'var(--ma-edge)', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-orbit-r:${radius}px`, `--ma-orbit-dur:${duration}s`, `--ma-orbit-size:${size}px`, `--ma-orbit-color:${color}`, count ? `--ma-orbit-n:${count}` : '', typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-orbit', className]} data-ma-orbit data-path={path ? '' : undefined} data-reverse={reverse ? '' : undefined} style={inline} {...rest}>
<slot />
</div>
<style is:global>
@layer components {
:where(.ma-orbit) {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
pointer-events: none;
}
:where(.ma-orbit[data-path])::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: calc(var(--ma-orbit-r, 100px) * 2);
height: calc(var(--ma-orbit-r, 100px) * 2);
translate: -50% -50%;
border: 1px solid var(--ma-orbit-color, var(--ma-edge));
border-radius: 50%;
}
/* count from the children when not given — the ring with n children matches :has(> :nth-child(n):last-child) */
:where(.ma-orbit:has(> :nth-child(1):last-child)) { --ma-orbit-n: 1; }
:where(.ma-orbit:has(> :nth-child(2):last-child)) { --ma-orbit-n: 2; }
:where(.ma-orbit:has(> :nth-child(3):last-child)) { --ma-orbit-n: 3; }
:where(.ma-orbit:has(> :nth-child(4):last-child)) { --ma-orbit-n: 4; }
:where(.ma-orbit:has(> :nth-child(5):last-child)) { --ma-orbit-n: 5; }
:where(.ma-orbit:has(> :nth-child(6):last-child)) { --ma-orbit-n: 6; }
:where(.ma-orbit:has(> :nth-child(7):last-child)) { --ma-orbit-n: 7; }
:where(.ma-orbit:has(> :nth-child(8):last-child)) { --ma-orbit-n: 8; }
:where(.ma-orbit:has(> :nth-child(9):last-child)) { --ma-orbit-n: 9; }
:where(.ma-orbit:has(> :nth-child(10):last-child)) { --ma-orbit-n: 10; }
:where(.ma-orbit:has(> :nth-child(11):last-child)) { --ma-orbit-n: 11; }
:where(.ma-orbit:has(> :nth-child(12):last-child)) { --ma-orbit-n: 12; }
:where(.ma-orbit > :nth-child(1)) { --ma-orbit-i: 0; }
:where(.ma-orbit > :nth-child(2)) { --ma-orbit-i: 1; }
:where(.ma-orbit > :nth-child(3)) { --ma-orbit-i: 2; }
:where(.ma-orbit > :nth-child(4)) { --ma-orbit-i: 3; }
:where(.ma-orbit > :nth-child(5)) { --ma-orbit-i: 4; }
:where(.ma-orbit > :nth-child(6)) { --ma-orbit-i: 5; }
:where(.ma-orbit > :nth-child(7)) { --ma-orbit-i: 6; }
:where(.ma-orbit > :nth-child(8)) { --ma-orbit-i: 7; }
:where(.ma-orbit > :nth-child(9)) { --ma-orbit-i: 8; }
:where(.ma-orbit > :nth-child(10)) { --ma-orbit-i: 9; }
:where(.ma-orbit > :nth-child(11)) { --ma-orbit-i: 10; }
:where(.ma-orbit > :nth-child(12)) { --ma-orbit-i: 11; }
:where(.ma-orbit > *) {
position: absolute;
left: 0;
top: 0;
display: grid;
place-items: center;
width: var(--ma-orbit-size, 40px);
height: var(--ma-orbit-size, 40px);
margin: calc(var(--ma-orbit-size, 40px) / -2);
pointer-events: auto;
/* the start angle, from the index and the count */
--ma-orbit-a: calc(var(--ma-orbit-i, 0) * 360deg / var(--ma-orbit-n, 1));
transform: rotate(var(--ma-orbit-a)) translate(var(--ma-orbit-r, 100px)) rotate(calc(-1 * var(--ma-orbit-a)));
will-change: transform;
animation: ma-orbit var(--ma-orbit-dur) linear infinite;
}
:where(.ma-orbit[data-reverse] > *) {
animation-direction: reverse;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-orbit > *) {
animation: none;
}
}
}
@keyframes ma-orbit {
from {
transform: rotate(var(--ma-orbit-a)) translate(var(--ma-orbit-r, 100px)) rotate(calc(-1 * var(--ma-orbit-a)));
}
to {
transform: rotate(calc(var(--ma-orbit-a) + 360deg)) translate(var(--ma-orbit-r, 100px)) rotate(calc(-1 * var(--ma-orbit-a) - 360deg));
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
radius | number | 100 | Orbit radius in px. |
duration | number | 20 | Seconds per revolution. |
reverse | boolean | false | Run counter-clockwise. |
path | boolean | true | Draw the orbit line. |
count | number | the number of children | Items on the ring, when the children are not the whole ring (or for a browser without :has()). |
size | number | 40 | Item cell in px; each child is centred in a square of this size. |
color | string | var(--ma-edge) | Orbit line colour. |
Reduced motion
The items rest at their start angles on the ring.
With ClientRouter
CSS-only; nothing to rebind.
Why it is built this way
- rotate(a) translate(r) rotate(−a), animated from a to a + 360° on both rotations: the item circles the centre and never turns itself. One animation per item, no counter-rotating wrapper, no JavaScript.
- The start angle is the index over the count; the index is :nth-child, the count is :has(> :nth-child(n):last-child), so a ring of any size up to twelve needs no props at all.
- The whole transform is one property on the compositor; the cell is a fixed square so the icon is centred by layout, not by measurement.
- Linear timing, because it orbits forever.
Replaces: OrbitingCircles (Magic UI) · Orbit (Cult UI). See the migration table.
Related
Circular Text
TextA ring of text turning slowly around an icon, a logo, or nothing.
Ripple
BackgroundsRings expand from a point and fade, one after another, like a pulse.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown