# Orbiting Circles (Moonarc)

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.

- Import: `import OrbitingCircles from '@moonarc/core/OrbitingCircles'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/orbiting-circles.json`
- Tier A · category background · trigger always
- Readout: `<OrbitingCircles radius={100} duration={20}>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4); elsewhere: pass `count`; without :has() and without count the items stack at one angle
- Measured cost: 0 B JS (CSS 6.5 kB raw)
- Page: https://moonarc.dev/components/orbiting-circles/

## 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. |

## Usage

```astro
<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>
```

## Reduced motion

The items rest at their start angles on the ring.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- 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)

## Source

```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>

```
