Skip to content

Components / Backgrounds

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

ma

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

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

Baseline · widely available

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 moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/orbiting-circles.json

The 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.astro
---
/**
 * 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

PropTypeDefaultDescription
radiusnumber100Orbit radius in px.
durationnumber20Seconds per revolution.
reversebooleanfalseRun counter-clockwise.
pathbooleantrueDraw the orbit line.
countnumberthe number of childrenItems on the ring, when the children are not the whole ring (or for a browser without :has()).
sizenumber40Item cell in px; each child is centred in a square of this size.
colorstringvar(--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

Replaces: OrbitingCircles (Magic UI) · Orbit (Cult UI). See the migration table.

Animated Beam

Backgrounds

Sends packets of light along curves between nodes.

every browseralways

A ring of text turning slowly around an icon, a logo, or nothing.

every browseralways

Ripple

Backgrounds

Rings expand from a point and fade, one after another, like a pulse.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown