# Ripple (Moonarc)

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.

- Import: `import Ripple from '@moonarc/core/Ripple'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/ripple.json`
- Tier A · category background · trigger always
- Readout: `<Ripple count={4}>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 4.7 kB raw)
- Page: https://moonarc.dev/components/ripple/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | `4` | Rings in flight, 1–8; evenly spaced over the duration. |
| `duration` | `number` | `4` | Seconds a ring takes to expand and fade. |
| `color` | `string` | `'currentColor'` | Ring colour. |
| `size` | `number` | `1.4` | Final diameter as a fraction of the box's width (the positioned parent it fills), capped at the nearest container's inline size. |
| `origin` | `string` | `'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. |
| `width` | `number` | `1` | Ring thickness in px. |

## Usage

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

## Reduced motion

Four static concentric rings at a quarter opacity.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Ease-out: a ring slows as it grows, like water. Linear rings look like radar.
- Delays are duration / count, so the pulse is even and the last ring hands off to the first.
- Opacity starts at 0.6, never 1: rings are texture behind content, not a subject.

## Replaces

- Ripple (Magic UI)
- pulse rings (custom CSS)

## Source

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

```
