Skip to content

Components / Backgrounds

Aurora Background

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. No WebGL, no canvas, no blur filter: every layer is a transform on the compositor.

Live demo

Ambient.

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

every browser.

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 Aurora from '@moonarc/core/Aurora';
---
<section class="relative">
  <Aurora />
  <h1 class="relative">Content above the field</h1>
</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/aurora.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.

Aurora.astro
---
/**
 * Aurora — ambient gradient field, zero JS, no WebGL.
 *
 * Four layers, each on the compositor: three soft colour fields drifting at
 * three coprime periods so the pattern never repeats, a slow hue rotation on
 * the whole field so the colours themselves move, and a grain layer stepped
 * at 12 fps so the surface reads as film, not vector. No filter: blur() — the
 * softness comes from the gradient falloff itself, so every frame costs a
 * transform (and one accelerated hue-rotate), never a re-blur. Fills its
 * positioned parent.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Three colours, any CSS colour. */
  colors?: [string, string, string];
  /** Seconds per drift cycle of the slowest field; the others run at 0.72× and 0.55×. */
  duration?: number;
  /** Overall strength, 0–1. */
  opacity?: number;
  /** Degrees the hues wander either side of the given colours; 0 pins them. */
  hue?: number;
  /** Grain strength, 0–1; 0 removes the layer. */
  grain?: number;
}

const {
  colors = ['oklch(70% 0.19 300)', 'oklch(78% 0.15 200)', 'oklch(75% 0.17 350)'],
  duration = 22,
  opacity = 0.55,
  hue = 40,
  grain = 0.5,
  class: className,
  style,
  ...rest
} = Astro.props;
const vars = [
  `--ma-aurora-1:${colors[0]}`,
  `--ma-aurora-2:${colors[1]}`,
  `--ma-aurora-3:${colors[2]}`,
  `--ma-aurora-duration:${duration}s`,
  `--ma-aurora-opacity:${opacity}`,
  `--ma-aurora-hue:${hue}deg`,
  `--ma-aurora-grain:${grain}`,
  typeof style === 'string' ? style : '',
]
  .filter(Boolean)
  .join(';');
---

<div
  class:list={['ma-aurora', className]}
  aria-hidden="true"
  data-grain={grain > 0 ? '' : undefined}
  style={vars}
  {...rest}
>
  <span class="ma-aurora__field">
    <span class="ma-aurora__blob"></span>
    <span class="ma-aurora__blob"></span>
    <span class="ma-aurora__blob"></span>
  </span>
</div>

<style is:global>
  @layer components {
    :where(.ma-aurora) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      opacity: var(--ma-aurora-opacity, 0.55);
      contain: strict;
    }
    :where(.ma-aurora__field) {
      position: absolute;
      inset: -10%;
      will-change: filter;
      animation: ma-aurora-hue calc(var(--ma-aurora-duration, 22s) * 2.6) ease-in-out infinite alternate;
    }
    :where(.ma-aurora__blob) {
      position: absolute;
      width: 70%;
      aspect-ratio: 1;
      border-radius: 50%;
      background: radial-gradient(circle at center, var(--ma-aurora-c) 0%, color-mix(in oklch, var(--ma-aurora-c), transparent 55%) 28%, transparent 64%);
      will-change: translate, scale;
      animation: ma-aurora-drift var(--ma-aurora-duration, 22s) ease-in-out infinite alternate;
    }
    :where(.ma-aurora__blob:nth-child(1)) {
      --ma-aurora-c: var(--ma-aurora-1);
      top: -25%;
      left: -15%;
    }
    :where(.ma-aurora__blob:nth-child(2)) {
      --ma-aurora-c: var(--ma-aurora-2);
      top: 5%;
      right: -20%;
      animation-name: ma-aurora-drift-2;
      animation-duration: calc(var(--ma-aurora-duration, 22s) * 0.72);
      animation-delay: calc(var(--ma-aurora-duration, 22s) / -3);
    }
    :where(.ma-aurora__blob:nth-child(3)) {
      --ma-aurora-c: var(--ma-aurora-3);
      bottom: -35%;
      left: 15%;
      animation-name: ma-aurora-drift-3;
      animation-duration: calc(var(--ma-aurora-duration, 22s) * 0.55);
      animation-delay: calc(var(--ma-aurora-duration, 22s) / -1.5);
    }
    /* grain: a tiled SVG turbulence, stepped so it flickers like film at 12 fps; translate only, so it stays on the compositor */
    :where(.ma-aurora[data-grain])::after {
      content: '';
      position: absolute;
      inset: -20%;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
      background-size: 200px 200px;
      mix-blend-mode: overlay;
      opacity: calc(var(--ma-aurora-grain, 0.5) * 0.5);
      animation: ma-aurora-grain 0.5s steps(6) infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-aurora__field),
      :where(.ma-aurora__blob),
      :where(.ma-aurora[data-grain])::after {
        animation: none;
        will-change: auto;
      }
    }
  }

  @keyframes ma-aurora-drift {
    0% {
      translate: 0 0;
      scale: 1;
    }
    50% {
      translate: 28% 14%;
      scale: 1.25;
    }
    100% {
      translate: -14% 30%;
      scale: 0.9;
    }
  }
  @keyframes ma-aurora-drift-2 {
    0% {
      translate: 0 0;
      scale: 1.1;
    }
    50% {
      translate: -30% 20%;
      scale: 0.85;
    }
    100% {
      translate: -10% -18%;
      scale: 1.2;
    }
  }
  @keyframes ma-aurora-drift-3 {
    0% {
      translate: 0 0;
      scale: 0.95;
    }
    50% {
      translate: 24% -30%;
      scale: 1.2;
    }
    100% {
      translate: -20% -8%;
      scale: 1;
    }
  }
  @keyframes ma-aurora-hue {
    from {
      filter: hue-rotate(calc(var(--ma-aurora-hue, 40deg) * -1));
    }
    to {
      filter: hue-rotate(var(--ma-aurora-hue, 40deg));
    }
  }
  @keyframes ma-aurora-grain {
    to {
      translate: 6% 4%;
    }
  }
</style>

Props

PropTypeDefaultDescription
colors[string, string, string]violet, cyan, rose (oklch)Three colours, any CSS colour. Keep chroma moderate on light backgrounds.
durationnumber22Seconds per drift cycle of the slowest field; the others run at 0.72× and 0.55× so the pattern never repeats. Under 12 stops being ambient.
huenumber40Degrees the hues wander either side of the given colours over 2.6 cycles; 0 pins them.
grainnumber0.5Grain strength, 0–1; 0 removes the layer.
opacitynumber0.55Overall strength, 0–1.

Reduced motion

The fields stop drifting, the hue stops turning and the grain holds still: a static gradient with texture.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: Aurora (React Bits, OGL) · AuroraBackground (Aceternity) · BackgroundGradientAnimation (Aceternity). See the migration table.

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

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