← All particles

Touch Ripple

A ring of particles thrown out from the tap point, exactly one per tap.

interactiveplayfulfriendly28 particles · light · canvas-2d · interaction · finite
Interactive · try it
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.

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

/**
 * Vibary · Touch Ripple
 *
 * A ring of particles thrown out from wherever the surface was tapped —
 * one per tap, no matter how fast the taps come.
 *
 * The technique that gives the ripple an edge: the particles are slowed
 * by exponential drag rather than by a timer. Drag gives every particle
 * the same reach, speed divided by the drag constant, so they all run
 * out of travel at the same distance and the ring arrives somewhere and
 * stops. A timed outward tween instead produces a puff that dissolves
 * mid-flight with no boundary, and the tap loses the sense of having
 * landed. Because the reach is a formula rather than a number, changing
 * the launch speed moves the boundary and nothing else needs retuning.
 *
 * Drop it inside any positioned element — a button, a tile, a card —
 * and it listens on that parent for a tap, or for Enter and Space when
 * the parent is focusable. The canvas itself never takes the pointer.
 *
 * 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 TouchRippleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Particles thrown by one tap. */
  count?: number;
  /** Particle fill. */
  color?: string;
  /**
   * Fire one at the centre after this many idle seconds, for previews
   * and screens nobody is touching. 0 turns it off, which is the
   * default — a real surface should only answer real input.
   */
  idleDemoSeconds?: number;
  /** Fires once per ripple, on the tap that starts it. */
  onRipple?: () => void;
};

type VariantConfig = {
  /** Multiplier applied to `count`. */
  density: number;
  /** Launch speed in px per second. */
  speed: number;
  /** Drag constant. Reach is speed / drag, in px. */
  drag: number;
  /** Particle radius in px at launch. */
  dot: number;
  /** Seconds from tap to gone. */
  life: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short acknowledgement that stays close to the finger.
  subtle: { density: 0.7, speed: 190, drag: 5.2, dot: 1.6, life: 0.8 },
  // Reads clearly on a button or a tile. All-purpose.
  default: { density: 1, speed: 260, drag: 4.4, dot: 1.9, life: 0.95 },
  // Throws much further, for a full-surface tap.
  playful: { density: 1.3, speed: 360, drag: 3.8, dot: 2.2, life: 1.15 },
};

type Particle = {
  x: number;
  y: number;
  angle: number;
  speed: number;
  age: number;
  life: number;
  size: number;
};

export default function TouchRipple({
  variant = "default",
  count = 28,
  color = "#6C8CF5",
  idleDemoSeconds = 0,
  onRipple,
}: TouchRippleProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Callbacks are read through a ref so an inline arrow from the parent
  // can't tear down the listeners on every render.
  const rippleRef = useRef(onRipple);
  useEffect(() => {
    rippleRef.current = onRipple;
  }, [onRipple]);

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

    const config = VARIANTS[variant];
    const perRipple = Math.max(6, Math.round(count * config.density));
    const capacity = perRipple * 3;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let ratio = 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);
    };
    resize();

    let particles: Particle[] = [];
    /** Taps recorded raw, converted to canvas space once per frame. A
     * queue rather than a slot, so two fingers landing between frames
     * still make two ripples. */
    let pendingTaps: { clientX: number; clientY: number }[] = [];
    let frame = 0;
    let running = false;
    let last = performance.now();
    let lastActivity = performance.now();

    /** Where drag runs a particle out of travel. The ripple's edge. */
    const reach = config.speed / config.drag;

    const draw = () => {
      context.clearRect(0, 0, width, height);
      context.fillStyle = color;
      for (const particle of particles) {
        const t = particle.age / particle.life;
        const fade = Math.min(1, particle.age / 0.05) * Math.pow(Math.max(0, 1 - t), 1.8);
        if (fade <= 0.01) continue;
        context.globalAlpha = Math.min(0.85, fade);
        context.beginPath();
        context.arc(particle.x, particle.y, particle.size * (0.6 + 0.4 * (1 - t)), 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

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

      // One rect read per frame at most, not one per tap: the handler
      // only records client coordinates, so a burst of taps on a page
      // with live layout forces synchronous layout once.
      if (pendingTaps.length > 0) {
        const rect = canvas.getBoundingClientRect();
        for (const tap of pendingTaps) {
          ripple(tap.clientX - rect.left, tap.clientY - rect.top, true);
        }
        pendingTaps = [];
      }

      const decay = Math.exp(-config.drag * delta);
      for (const particle of particles) {
        particle.age += delta;
        // Exact integral of the drag over this frame, so the reach is
        // the same on a 30 Hz screen as on a 120 Hz one.
        const step = (particle.speed * (1 - decay)) / config.drag;
        particle.x += Math.cos(particle.angle) * step;
        particle.y += Math.sin(particle.angle) * step;
        particle.speed *= decay;
      }
      particles = particles.filter((particle) => particle.age < particle.life);

      draw();

      if (particles.length === 0) {
        // Nothing left to move: stop the loop rather than idling on it.
        running = false;
        frame = 0;
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running) return;
      running = true;
      last = performance.now();
      frame = requestAnimationFrame(tick);
    };

    const ripple = (x: number, y: number, fromInput: boolean) => {
      for (let index = 0; index < perRipple; index++) {
        const angle = (index / perRipple) * Math.PI * 2 + (Math.random() - 0.5) * 0.18;
        const variation = 0.75 + Math.random() * 0.55;
        // Reduced motion: land the particles where drag would have left
        // them and let only opacity change. The tap is still answered,
        // and answered in the same place.
        const offset = reduced ? reach * variation : 0;
        particles.push({
          x: x + Math.cos(angle) * offset,
          y: y + Math.sin(angle) * offset,
          angle,
          speed: reduced ? 0 : config.speed * variation,
          age: 0,
          life: config.life * (0.85 + Math.random() * 0.3),
          size: config.dot * (0.7 + Math.random() * 0.6),
        });
      }
      if (particles.length > capacity) particles.splice(0, particles.length - capacity);
      lastActivity = performance.now();
      // Only real input is reported: a consumer wiring this to haptics
      // or a sound should not be fired by the idle preview.
      if (fromInput) rippleRef.current?.();
      start();
    };

    // pointerdown only: it fires once per pointer, so a tap can never
    // produce two ripples the way a mouse/touch listener pair does.
    // Deliberately does no layout work and no canvas-space math: it
    // stores the tap and lets the frame loop convert it.
    const onPointerDown = (event: PointerEvent) => {
      pendingTaps.push({ clientX: event.clientX, clientY: event.clientY });
      start();
    };

    // Keyboard activation of the parent control gets the same answer,
    // from the middle. `repeat` is filtered so holding the key does not
    // machine-gun ripples.
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.repeat) return;
      if (event.key !== "Enter" && event.key !== " " && event.key !== "Spacebar") return;
      ripple(width / 2, height / 2, true);
    };

    surface.addEventListener("pointerdown", onPointerDown);
    surface.addEventListener("keydown", onKeyDown);
    window.addEventListener("resize", resize);

    let idle = 0;
    if (idleDemoSeconds > 0) {
      idle = window.setInterval(() => {
        if (particles.length > 0) return;
        if (performance.now() - lastActivity < idleDemoSeconds * 1000) return;
        ripple(width / 2, height / 2, false);
      }, 400);
    }

    return () => {
      cancelAnimationFrame(frame);
      if (idle) clearInterval(idle);
      surface.removeEventListener("pointerdown", onPointerDown);
      surface.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("resize", resize);
    };
  }, [variant, count, color, idleDemoSeconds]);

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

About this effect

Physical feedback for a surface worth touching — a reaction pad, a tile, a big confirm control. The particles are slowed by exponential drag rather than by a timer, which gives every one of them the same reach, so the ring arrives at a distance and stops instead of dissolving mid-flight; a tap that lands somewhere feels answered in a way a fading puff never does. It attaches to whichever positioned element it is dropped inside and listens there, so the canvas never takes the pointer, and it answers Enter and Space as well as taps when that parent is focusable. Only pointerdown is listened for, so a tap can never produce two ripples, and the animation loop stops itself once the last particle has gone.

Reaction pad in a live sessionTap feedback on a tileConfirm control acknowledgementKiosk or display surface

Related effects