← All particles

Signal Ripple

Rings of dots leaving a point on a steady cadence, thinning as they spread.

statuscalmfuturistic66 particles · light · 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.

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

/**
 * Vibary · Signal Ripple
 *
 * Rings of dots leaving a point on a steady cadence — a broadcast, a
 * sync reaching out, a beacon.
 *
 * The technique that makes the falloff read as physics rather than as a
 * timer: each ring carries a *fixed* number of dots. As its radius
 * grows those dots are spread around a longer and longer circumference,
 * so the ring visibly thins on its way out, and opacity is tied to that
 * same spacing rather than to elapsed time. Energy spread over more
 * space is the reason it fades — which is why it still reads correctly
 * if you change the speed, the reach or the cadence.
 *
 * Supporting detail: consecutive rings are offset by the golden angle,
 * so their dots never line up into radial spokes.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `everySeconds`, `origin`, `color`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SignalRippleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Seconds between rings. Drive this from a real heartbeat if you have one. */
  everySeconds?: number;
  /** Where the rings leave from, as a fraction of the surface. */
  origin?: { x: number; y: number };
  /** Dot and emitter fill. */
  color?: string;
  /** Fires as each ring leaves the emitter. */
  onPulse?: () => void;
};

type VariantConfig = {
  /** Dots per ring. Fixed for the ring's whole life — that is the point. */
  dots: number;
  /** Seconds for a ring to travel its full reach. */
  life: number;
  /** Reach as a fraction of the surface's half-minimum dimension. */
  reach: number;
  /** Dot radius in px at the emitter. */
  dot: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A slow, sparse ring that barely reaches the edge.
  subtle: { dots: 22, life: 3.2, reach: 0.86, dot: 1.4 },
  // Reads as a broadcast at a glance. All-purpose.
  default: { dots: 30, life: 2.4, reach: 1, dot: 1.7 },
  // Quicker and denser, and it runs past the frame.
  playful: { dots: 40, life: 1.8, reach: 1.12, dot: 2 },
};

type Ring = {
  /** Seconds since it left the emitter. */
  age: number;
  /** Rotation of this ring's dots, so rings never align into spokes. */
  offset: number;
};

const CENTRE = { x: 0.5, y: 0.5 };
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));

export default function SignalRipple({
  variant = "default",
  everySeconds = 1.1,
  origin = CENTRE,
  color = "#4FA3D1",
  onPulse,
}: SignalRippleProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Callbacks and live values are read through refs so an inline arrow
  // or object literal from the parent can't restart the emitter on
  // every render.
  const pulseRef = useRef(onPulse);
  useEffect(() => {
    pulseRef.current = onPulse;
  }, [onPulse]);
  const originRef = useRef(origin);
  useEffect(() => {
    originRef.current = origin;
  }, [origin]);

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

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

    let width = 0;
    let height = 0;
    let ratio = 1;
    let maxRadius = 1;
    let seedRadius = 1;

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      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);
      maxRadius = (Math.min(width, height) / 2) * config.reach;
      // The radius at which a ring is at full strength — the spacing
      // every later radius is measured against.
      seedRadius = Math.max(4, maxRadius * 0.16);
    };

    const drawFrame = (rings: Ring[], emitter: number) => {
      context.clearRect(0, 0, width, height);
      const originX = width * originRef.current.x;
      const originY = height * originRef.current.y;
      context.fillStyle = color;

      for (const ring of rings) {
        const life = Math.min(1, ring.age / config.life);
        // Ease-out travel: rings bunch up as they slow near the edge,
        // which is what a spreading wavefront actually does.
        const radius = seedRadius + (maxRadius - seedRadius) * (1 - Math.pow(1 - life, 2.2));
        // The same dot budget over a longer circumference: this ratio is
        // both how thin the ring looks and how bright it is.
        const spread = seedRadius / radius;
        const alpha = spread * (1 - life * life) * 0.95;
        if (alpha <= 0.012) continue;
        const size = config.dot * (0.45 + spread * 0.55);

        context.globalAlpha = Math.min(0.85, alpha);
        for (let index = 0; index < config.dots; index++) {
          const angle = ring.offset + (index / config.dots) * Math.PI * 2;
          context.beginPath();
          context.arc(
            originX + Math.cos(angle) * radius,
            originY + Math.sin(angle) * radius,
            size,
            0,
            Math.PI * 2
          );
          context.fill();
        }
      }

      // The source, so the rings read as leaving somewhere rather than
      // as circles that happen to share a centre.
      context.globalAlpha = 0.55 + emitter * 0.35;
      context.beginPath();
      context.arc(originX, originY, config.dot * (1.5 + emitter * 1.3), 0, Math.PI * 2);
      context.fill();
      context.globalAlpha = 1;
    };

    resize();

    // Reduced motion: three rings held at three distances. The falloff
    // is spatial, so a frozen frame still shows the whole idea — a
    // bright tight source and a wide faint edge.
    if (reduced) {
      const still: Ring[] = [0.22, 0.52, 0.82].map((age, index) => ({
        age: age * config.life,
        offset: index * GOLDEN_ANGLE,
      }));
      drawFrame(still, 0);
      const onResizeStill = () => {
        resize();
        drawFrame(still, 0);
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let rings: Ring[] = [];
    let emitted = 0;
    let sinceEmit = interval;
    let emitter = 0;
    let frame = 0;
    let last = performance.now();

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

      sinceEmit += delta;
      if (sinceEmit >= interval) {
        sinceEmit -= interval;
        rings.push({ age: 0, offset: emitted * GOLDEN_ANGLE });
        emitted++;
        emitter = 1;
        pulseRef.current?.();
      }

      emitter = Math.max(0, emitter - delta / 0.45);
      for (const ring of rings) ring.age += delta;
      rings = rings.filter((ring) => ring.age < config.life);

      drawFrame(rings, emitter);
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);
    window.addEventListener("resize", resize);

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

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

About this effect

A broadcast made visible — a device announcing itself, a sync reaching outward, a beacon on a map pin. Each ring carries a fixed number of dots, so as its radius grows those dots spread around a longer circumference and the ring genuinely thins on the way out; opacity is tied to that spacing rather than to a timer, which is why the falloff still reads correctly when you change the speed, the reach or the cadence. Consecutive rings are rotated by the golden angle so their dots never line up into radial spokes, and a small emitter dot marks the source so the rings read as leaving somewhere.

Device discovery nearbyLive sync broadcastingMap pin announcing itselfPresence beacon on a dashboard

Related effects