Skip to content

Components / Backgrounds

Orb

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.

Live demo

Ask anything…

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.0 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 · newly available

Chrome 85 · Firefox 128 · Safari 16.4; needs property. Elsewhere: the sphere breathes; the hue does not drift.

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 Orb from '@moonarc/core/Orb';
---
<section class="relative grid min-h-[32rem] place-items-center">
  <Orb hue={250} size="24rem" />
  <form class="relative">…</form>
</section>
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/orb.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.

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

Props

PropTypeDefaultDescription
sizestring'18rem'Diameter, any CSS length.
huenumber250Base hue in degrees (oklch).
spreadnumber40Degrees between the three stops; the second and third sit one and two spreads along the wheel, the other way when negative.
chromanumber0.16Chroma 0–0.3; low reads as haze, high as neon.
colors[string, string, string]noneThree colours replacing the hue-derived stops; the hue then does not drift.
pulsenumber0.06Breath amplitude as a fraction of the size; 0 keeps it still.
durationnumber6Seconds per breath; the hue drifts over four breaths, the rim turns in three.
driftnumber60Degrees the hue wanders over a cycle; 0 pins it.
opacitynumber1Strength, 0–1.

Reduced motion

A still sphere at its base hue.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: Orb (React Bits) · Orb (Kokonut UI). See the migration table.

Ambient gradient field: three soft colour fields drifting at three coprime speeds, a slow hue rotation so the colours themselves move, and a film-grain layer stepped at 12 fps.

every browseralways

Particles

Backgrounds

A field of drifting dots that part around the cursor, on a canvas that draws only while on screen in a visible tab.

every browseralwayspointer

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