Skip to content

Components / Backgrounds

Ripple

Rings expand from a point and fade, one after another, like a pulse. Each ring is an element scaling and fading on its own delay; pure CSS, compositor only. Fills its positioned parent.

Live demo

live

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

Measured

JavaScript of its own

0 B

CSS 4.7 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 Ripple from '@moonarc/core/Ripple';
---
<div class="relative grid h-64 place-items-center">
  <Ripple color="var(--accent)" />
  <button class="relative rounded-full bg-fg px-5 py-3 text-bg">Live</button>
</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/ripple.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.

Ripple.astro
---
/**
 * Ripple — rings expand from a point and fade, one after another. Zero JS:
 * each ring is an element scaling and fading on its own delay, so the set
 * reads as a continuous pulse. Fills its positioned parent.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Number of rings in flight. */
  count?: number;
  /** Seconds a ring takes to expand and fade. */
  duration?: number;
  /** Ring colour. */
  color?: string;
  /** Final diameter as a fraction of the box's width, capped at the nearest container's inline size. */
  size?: number;
  /** Origin, x then y as in background-position: lengths, percentages or keywords; one value centres the other axis. */
  origin?: string;
  /** Ring thickness in px. */
  width?: number;
}

const { count = 4, duration = 4, color = 'currentColor', size = 1.4, origin = '50% 50%', width = 1, class: className, style, ...rest } = Astro.props;
const n = Math.max(1, Math.min(count, 8));
// origin → one left and one top. Written into the inset shorthand it was top and right (inset is top right bottom left),
// so the default 50% 50% put the rings' centre half a ring left of the box's. Split at the top level (a calc() keeps its
// spaces), keywords to percentages, and "top left" read as y x, as background-position reads it.
const parts: string[] = [];
let depth = 0;
let part = '';
for (const ch of origin.trim()) {
  if (ch === '(') depth++;
  else if (ch === ')') depth--;
  if (depth === 0 && /\s/.test(ch)) {
    if (part) parts.push(part);
    part = '';
  } else part += ch;
}
if (part) parts.push(part);
let [x = 'center', y = 'center'] = parts;
if (/^(top|bottom)$/.test(x) || /^(left|right)$/.test(y)) [x, y] = [y, x];
const KEYWORD: Record<string, string> = { left: '0%', top: '0%', center: '50%', right: '100%', bottom: '100%' };
const vars = [`--ma-rp-n:${n};--ma-rp-dur:${duration}s;--ma-rp-color:${color};--ma-rp-size:${size};--ma-rp-x:${KEYWORD[x] ?? x};--ma-rp-y:${KEYWORD[y] ?? y};--ma-rp-w:${width}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-ripple', className]} aria-hidden="true" style={vars} {...rest}>
  {Array.from({ length: n }, (_, i) => <i style={`--ma-rp-i:${i}`}></i>)}
</div>

<style is:global>
  @layer components {
    :where(.ma-ripple) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      contain: strict;
    }
    :where(.ma-ripple > i) {
      position: absolute;
      left: var(--ma-rp-x, 50%);
      top: var(--ma-rp-y, 50%);
      width: calc(min(100%, 100cqi) * var(--ma-rp-size, 1.4));
      aspect-ratio: 1;
      border: var(--ma-rp-w, 1px) solid var(--ma-rp-color, currentColor);
      border-radius: 50%;
      translate: -50% -50%;
      opacity: 0;
      scale: 0;
      will-change: scale, opacity;
      animation: ma-ripple var(--ma-rp-dur, 4s) var(--ma-ease-out) infinite;
      animation-delay: calc(var(--ma-rp-i, 0) * var(--ma-rp-dur, 4s) / var(--ma-rp-n, 4));
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-ripple > i) {
        animation: none;
        opacity: 0.25;
        scale: calc(0.3 + var(--ma-rp-i, 0) * 0.2);
      }
    }
  }

  @keyframes ma-ripple {
    0% {
      opacity: 0.6;
      scale: 0;
    }
    100% {
      opacity: 0;
      scale: 1;
    }
  }
</style>

Props

PropTypeDefaultDescription
countnumber4Rings in flight, 1–8; evenly spaced over the duration.
durationnumber4Seconds a ring takes to expand and fade.
colorstring'currentColor'Ring colour.
sizenumber1.4Final diameter as a fraction of the box's width (the positioned parent it fills), capped at the nearest container's inline size.
originstring'50% 50%'Where the rings start, x then y as in background-position: lengths, percentages or the keywords left, center, right, top and bottom ("top left" works too); one value centres the other axis. The centre of every ring sits on this point.
widthnumber1Ring thickness in px.

Reduced motion

Four static concentric rings at a quarter opacity.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: Ripple (Magic UI) · pulse rings (custom CSS). 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

Dot Pattern

Backgrounds

A field of dots behind content, faded toward the edges, with an optional slow pulse.

every browseralways

Spinner

Loading

An indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge.

every browseralways

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