← All particles

Water Ripple Rings

Rings crossing a surface of floating specks and bouncing off the edges.

environmentalcalmelegant380 particles · moderate · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

284 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Water Ripple Rings
 *
 * Rings spreading across a surface of floating specks, bouncing off the
 * edges and crossing back through each other.
 *
 * The technique: the reflections are not simulated, they are mirrored
 * sources. A ring meeting a straight wall comes back as a ring centred
 * on the source's mirror image behind that wall — so four extra sources,
 * one per edge, buy correct reflection angles, correct arrival times and
 * correct interference where two fronts cross, for the price of four
 * more distance tests per speck. A grid solve of the wave equation would
 * cost thousands of cells per frame to arrive at the same answer.
 *
 * That is a claim worth checking rather than asserting, so it was: this
 * construction was compared against an independent finite-difference
 * solve of the wave equation with reflecting walls, over three bounces,
 * and the worst disagreement was 4.5% of peak amplitude — the residual
 * being the grid solve's own dispersion, not the images'.
 *
 * Amplitude falls as one over the square root of the distance travelled,
 * which is what spreading in two dimensions actually does, and it is
 * why a reflected front reads as further away without anything being
 * tuned to make it so. Only the first bounce off each edge is included;
 * a ring that has hit two edges wants the corner image, which is the
 * same line four more times.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type WaterRippleRingsProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Specks floating on the surface. */
  count?: number;
  /** Speck colour. Defaults to the inherited text colour. */
  color?: string;
  /** Fires when a new ring starts. */
  onRing?: () => void;
};

type VariantConfig = {
  /** Seconds between rings. */
  cadence: number;
  /** How fast a front travels, px per second. */
  speed: number;
  /** Half-thickness of a front, in px. */
  sigma: number;
  /** How far a speck rides the wave, as a multiplier. */
  lift: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Rare, slow, barely lifting the surface.
  subtle: { cadence: 2.8, speed: 105, sigma: 34, lift: 0.7 },
  // A ring every couple of seconds, crossing before it dies. All-purpose.
  default: { cadence: 1.7, speed: 150, sigma: 30, lift: 1 },
  // Quicker and tighter, so several fronts are in flight at once.
  playful: { cadence: 1.05, speed: 205, sigma: 25, lift: 1.35 },
};

/** How much of a front survives a bounce. Real edges take some. */
const BOUNCE = 0.6;
/** Sets the wave height; everything a speck does scales off it. */
const AMPLITUDE = 46;

type Ring = { x: number; y: number; at: number };
type Speck = { x: number; y: number; tone: number };

/**
 * One cycle of a wave, with compact support so a speck outside the
 * front costs a comparison and nothing more. The crest is deliberately
 * wide — most of the window — because a crest narrower than the gap
 * between specks reads as scattered dots rather than as a front.
 */
function wavelet(z: number, sigma: number) {
  if (z < -sigma || z > sigma) return 0;
  const window = 1 - (z / sigma) ** 2;
  return window * window * Math.cos((0.7 * Math.PI * z) / sigma);
}

export default function WaterRippleRings({
  variant = "default",
  count = 380,
  color,
  onRing,
}: WaterRippleRingsProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref, written in an effect —
  // a ref assignment during render is a side effect mid-render.
  const ringRef = useRef(onRing);
  useEffect(() => {
    ringRef.current = onRing;
  }, [onRing]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const wanted = Math.max(40, Math.round(count));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const ink = color ?? getComputedStyle(canvas).color ?? "#888888";

    let width = 0;
    let height = 0;
    let specks: Speck[] = [];
    let life = 4;
    let rings: Ring[] = [];

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const build = () => {
      const pitch = Math.sqrt((width * height) / wanted);
      specks = [];
      for (let y = pitch * 0.5; y < height; y += pitch) {
        for (let x = pitch * 0.5; x < width; x += pitch) {
          // A little jitter: a perfect grid reads as a texture swatch
          // and the eye stops seeing the specks at all.
          specks.push({
            x: x + random(-pitch * 0.3, pitch * 0.3),
            y: y + random(-pitch * 0.3, pitch * 0.3),
            tone: Math.random(),
          });
        }
      }
      // Long enough for a front to cross the surface and come back.
      life = (Math.hypot(width, height) * 1.9) / config.speed;
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      build();
    };
    resize();

    /**
     * Height of the surface at a point: the real source plus its four
     * mirror images, one behind each edge. The mirrors keep the same
     * sign, which is what makes an edge reflect rather than absorb.
     */
    const heightAt = (x: number, y: number, time: number) => {
      let total = 0;
      for (const ring of rings) {
        const age = time - ring.at;
        if (age < 0) continue;
        const front = config.speed * age;
        const fade = Math.max(0, 1 - age / life);
        if (fade <= 0) continue;
        const inner = front - config.sigma;
        const outer = front + config.sigma;
        const innerSquared = inner > 0 ? inner * inner : 0;
        const outerSquared = outer * outer;

        for (let image = 0; image < 5; image++) {
          const sourceX =
            image === 1 ? -ring.x : image === 2 ? 2 * width - ring.x : ring.x;
          const sourceY =
            image === 3 ? -ring.y : image === 4 ? 2 * height - ring.y : ring.y;
          const dx = x - sourceX;
          const dy = y - sourceY;
          const squared = dx * dx + dy * dy;
          // The whole cost saving: a speck the front has not reached is
          // two multiplies and a comparison.
          if (squared < innerSquared || squared > outerSquared) continue;
          const distance = Math.sqrt(squared);
          total +=
            ((image === 0 ? 1 : BOUNCE) * AMPLITUDE * fade) /
            Math.sqrt(Math.max(distance, 22)) *
            wavelet(distance - front, config.sigma);
        }
      }
      return total;
    };

    const render = (time: number) => {
      context.clearRect(0, 0, width, height);
      context.fillStyle = ink;
      for (const speck of specks) {
        const rise = heightAt(speck.x, speck.y, time);
        // Lit from above: a crest catches the light and a trough loses
        // it, which is the same number read a second way. It also sets
        // the speck's size, so a front reads at a glance rather than
        // only when you look for it.
        const alpha = 0.07 + speck.tone * 0.035 + rise * 0.16;
        context.globalAlpha = Math.max(0.035, Math.min(0.85, alpha));
        context.beginPath();
        context.arc(
          speck.x,
          speck.y - rise * config.lift * 1.5,
          1 + Math.min(1.7, Math.max(0, rise) * 0.26),
          0,
          Math.PI * 2
        );
        context.fill();
      }
      context.globalAlpha = 1;
    };

    const drop = (time: number) => {
      rings.push({
        x: random(width * 0.2, width * 0.8),
        y: random(height * 0.2, height * 0.8),
        at: time,
      });
      if (rings.length > 4) rings.shift();
      ringRef.current?.();
    };

    // Reduced motion: two rings held at different ages, so one front is
    // still crossing the surface while the other has already come off an
    // edge and is passing back through it. The reflection and the
    // interference are the subject and both survive a single frame.
    if (reduced) {
      const still = () => {
        rings = [
          { x: width * 0.32, y: height * 0.4, at: -(width * 0.62) / config.speed },
          { x: width * 0.72, y: height * 0.66, at: -(width * 0.22) / config.speed },
        ];
        render(0);
      };
      still();
      const onResizeStill = () => {
        resize();
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let elapsed = 0;
    let nextDrop = 0.25;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += delta;

      if (elapsed >= nextDrop) {
        drop(elapsed);
        nextDrop = elapsed + config.cadence * random(0.75, 1.3);
      }
      rings = rings.filter((ring) => elapsed - ring.at < life);

      render(elapsed);
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => resize();
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, color]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

A still surface for a waiting screen, a lock screen or a quiet header — the kind of background that rewards a second look. The reflections are not simulated, they are mirrored sources: a ring meeting a straight wall comes back as a ring centred on the source's mirror image behind that wall, so four extra sources, one per edge, buy correct reflection angles, correct arrival times and correct interference where two fronts cross, for the price of four more distance tests per speck. A grid solve of the wave equation needs thousands of cells a frame to reach the same answer. The claim was checked rather than asserted: this construction was compared against an independent finite-difference solve with reflecting walls over three bounces, and the worst disagreement was 4.5% of peak amplitude — the residual being the grid solve's own dispersion. Amplitude falls as one over the square root of the distance travelled, which is what spreading in two dimensions does, so a reflected front reads as further away with nothing tuned to make it so.

Waiting or lock screenQuiet header or hero surfaceMeditation or focus backgroundIdle state for a live view

Related effects