# Orb (Moonarc)

A sphere of light that breathes and slowly turns its colour. Three radial gradients on one element, soft at their edges so no blur filter is ever painted; the breath is a scale on the compositor and the colour shift is one registered number scaled to a drift angle every gradient adds to its hue. A conic highlight turns around the rim. Zero JS; centred in its positioned parent.

- Import: `import Orb from '@moonarc/core/Orb'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/orb.json`
- Tier A · category background · trigger always
- Readout: `<Orb hue={250} pulse={0.06}>`
- Browser support: newly (Chrome 85 · Firefox 128 · Safari 16.4); elsewhere: the sphere breathes; the hue does not drift
- Measured cost: 0 B JS (CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/orb/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` | `string` | `'18rem'` | Diameter, any CSS length. |
| `hue` | `number` | `250` | Base hue in degrees (oklch). |
| `spread` | `number` | `40` | Degrees between the three stops; the second and third sit one and two spreads along the wheel, the other way when negative. |
| `chroma` | `number` | `0.16` | Chroma 0–0.3; low reads as haze, high as neon. |
| `colors` | `[string, string, string]` | none | Three colours replacing the hue-derived stops; the hue then does not drift. |
| `pulse` | `number` | `0.06` | Breath amplitude as a fraction of the size; 0 keeps it still. |
| `duration` | `number` | `6` | Seconds per breath; the hue drifts over four breaths, the rim turns in three. |
| `drift` | `number` | `60` | Degrees the hue wanders over a cycle; 0 pins it. |
| `opacity` | `number` | `1` | Strength, 0–1. |

## Usage

```astro
<section class="relative grid min-h-[32rem] place-items-center">
  <Orb hue={250} size="24rem" />
  <form class="relative">…</form>
</section>
```

## Reduced motion

A still sphere at its base hue.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- No filter: blur() on a large element is a full-size paint every frame; gradients that are already soft cost nothing to move.
- One registered <number>, 0 → 1 with literal keyframes, is the whole colour animation: scaled by the drift and added to the hue of the three oklch stops, so the sphere turns through the wheel without a second layer and without hue-rotate(). Literal, because Firefox does not interpolate a registered property whose keyframe value is a var().
- Lightness follows the theme through the --ma-dark scalar: a lighter sphere on a light ground, a deeper one on dark.
- The rim is a conic highlight under a ring mask, rotating on the compositor. It is the one detail that makes a flat gradient read as a sphere.
- Breath runs on the in-out curve with alternate direction, so the sphere shrinks as well as grows.

## Replaces

- Orb (React Bits)
- Orb (Kokonut UI)

## Source

```astro
---
/**
 * Orb — a sphere of light that breathes and slowly turns its colour. Three
 * radial gradients on one element, already soft at their edges so no blur
 * filter is ever painted; the breath is a `scale` on the compositor and
 * the colour shift is one registered <number> (0 → 1) scaled to a drift
 * angle the gradients add to their hue — animate the number, every stop follows. A conic highlight turns
 * around the rim. Centred in its positioned parent.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Diameter, any CSS length. */
  size?: string;
  /** Base hue in degrees (oklch). */
  hue?: number;
  /** Degrees between the three stops; negative runs the other way around the wheel. */
  spread?: number;
  /** Chroma 0–0.3; low reads as haze, high as neon. */
  chroma?: number;
  /** Three colours that replace the hue-derived stops (then the hue does not drift). */
  colors?: [string, string, string];
  /** Breath amplitude as a fraction of the size, 0 keeps it still. */
  pulse?: number;
  /** Seconds per breath. */
  duration?: number;
  /** Degrees the hue drifts over a cycle; 0 pins it. */
  drift?: number;
  /** Opacity, 0–1. */
  opacity?: number;
}

const { size = '18rem', hue = 250, spread = 40, chroma = 0.16, colors, pulse = 0.06, duration = 6, drift = 60, opacity = 1, class: className, style, ...rest } = Astro.props;
const inline = [
  `--ma-orb-size:${size}`,
  `--ma-orb-hue:${hue}deg`,
  `--ma-orb-spread:${spread}deg`,
  `--ma-orb-chroma:${chroma}`,
  `--ma-orb-pulse:${pulse}`,
  `--ma-orb-dur:${duration}s`,
  `--ma-orb-drift:${drift}deg`,
  `--ma-orb-opacity:${opacity}`,
  colors && `--ma-orb-c1:${colors[0]};--ma-orb-c2:${colors[1]};--ma-orb-c3:${colors[2]}`,
  typeof style === 'string' ? style : '',
]
  .filter(Boolean)
  .join(';');
---

<div class:list={['ma-orb', className]} aria-hidden="true" data-fixed={colors ? '' : undefined} style={inline} {...rest}></div>

<style is:global>
  /* the drift, 0 → 1, animated with literal keyframes: Firefox does not interpolate a registered property whose keyframe value is a var() (7C) */
  @property --ma-orb-t {
    syntax: '<number>';
    inherits: false;
    initial-value: 0;
  }

  @layer components {
    :where(.ma-orb) {
      position: absolute;
      left: 50%;
      top: 50%;
      width: var(--ma-orb-size, 18rem);
      height: var(--ma-orb-size, 18rem);
      translate: -50% -50%;
      border-radius: 50%;
      pointer-events: none;
      opacity: var(--ma-orb-opacity, 1);
      /* the live hue offset: the drift scaled by the animated number */
      --ma-orb-h: calc(var(--ma-orb-t) * var(--ma-orb-drift, 60deg));
      --ma-orb-l: calc(72% - 14% * var(--ma-dark, 0));
      --ma-orb-s1: var(--ma-orb-c1, oklch(var(--ma-orb-l) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-h))));
      --ma-orb-s2: var(--ma-orb-c2, oklch(calc(var(--ma-orb-l) - 10%) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-spread, 40deg) + var(--ma-orb-h))));
      --ma-orb-s3: var(--ma-orb-c3, oklch(calc(var(--ma-orb-l) - 22%) var(--ma-orb-chroma, 0.16) calc(var(--ma-orb-hue, 250deg) + var(--ma-orb-spread, 40deg) * 2 + var(--ma-orb-h))));
      background:
        radial-gradient(circle at 32% 30%, color-mix(in oklch, var(--ma-orb-s1) 90%, white) 0%, transparent 46%),
        radial-gradient(circle at 70% 68%, var(--ma-orb-s3) 0%, transparent 55%),
        radial-gradient(circle at 50% 50%, var(--ma-orb-s2) 0%, color-mix(in oklch, var(--ma-orb-s2) 50%, transparent) 55%, transparent 72%);
      will-change: scale;
      animation:
        ma-orb-breathe var(--ma-orb-dur) var(--ma-ease-in-out) infinite alternate,
        ma-orb-hue calc(var(--ma-orb-dur) * 4) var(--ma-ease-in-out) infinite alternate;
    }
    :where(.ma-orb[data-fixed]) {
      animation-name: ma-orb-breathe;
    }
    /* the rim: a soft conic highlight turning around the sphere */
    :where(.ma-orb)::after {
      content: '';
      position: absolute;
      inset: 6%;
      border-radius: inherit;
      background: conic-gradient(from 0deg, transparent 0 55%, color-mix(in oklch, var(--ma-orb-s1) 55%, white) 72%, transparent 88%);
      mask-image: radial-gradient(circle, transparent 60%, #000 74%, transparent 84%);
      opacity: 0.7;
      will-change: rotate;
      animation: ma-orb-rim calc(var(--ma-orb-dur) * 3) linear infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-orb),
      :where(.ma-orb)::after {
        animation: none;
      }
    }
  }

  @keyframes ma-orb-breathe {
    from {
      scale: 1;
    }
    to {
      scale: calc(1 + var(--ma-orb-pulse, 0.06));
    }
  }
  @keyframes ma-orb-hue {
    from {
      --ma-orb-t: 0;
    }
    to {
      --ma-orb-t: 1;
    }
  }
  @keyframes ma-orb-rim {
    to {
      rotate: 360deg;
    }
  }
</style>

```
