← All particles

Wipe Particles

A directional wipe with a torn, grainy edge instead of a hard line.

revealminimalelegant160 particles · light · canvas-2d · automatic · finite
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.

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

/**
 * Vibary · Wipe Particles
 *
 * A directional wipe whose edge is made of grains rather than a hard
 * line: the cover recedes and crumbles into its own dust.
 *
 * The technique: the front is a threshold on a per-slice field, and the
 * particles are emitted by the crossing itself — a slice spawns grains
 * at the instant the front passes its own threshold, then never again.
 * Because the emission is a consequence of the boundary rather than a
 * second animation aimed at the same place, the dust and the edge cannot
 * drift out of sync, which is exactly what goes wrong when a mask and an
 * emitter are timed independently.
 *
 * The threshold mixes two low-frequency waves with a per-slice hash, so
 * the edge is torn rather than noisy — pure randomness per slice reads
 * as static, pure waves read as a ripple.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `direction`, `color`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type WipeDirection = "right" | "left" | "down" | "up";

export type WipeParticlesProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Which way the front travels. */
  direction?: WipeDirection;
  /** The cover's tone; the dust is the same material. */
  color?: string;
  /** Height of one slice across the front, in px. Smaller is finer. */
  sliceSize?: number;
  /** Fires once the front has cleared the far edge. */
  onRevealed?: () => void;
};

type VariantConfig = {
  /** Seconds for the front to cross the whole box. */
  seconds: number;
  /** How far the torn edge deviates from a straight line, in px. */
  ragged: number;
  /** Grains emitted per slice as the front passes it. */
  density: number;
  /** Grain drift along the wipe axis, in px per second. */
  drift: number;
  /** Grain radius in px. */
  grain: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A slow, almost straight edge with a thin dusting.
  subtle: { seconds: 1.6, ragged: 22, density: 3, drift: 18, grain: 1.5 },
  // Reads as a torn edge crossing the panel. All-purpose.
  default: { seconds: 1.15, ragged: 40, density: 4, drift: 34, grain: 1.8 },
  // A quick sweep with a wide, broken front and a heavier trail.
  playful: { seconds: 0.85, ragged: 66, density: 5, drift: 56, grain: 2.1 },
};

type Grain = {
  x: number;
  y: number;
  vx: number;
  vy: number;
  age: number;
  life: number;
  size: number;
};

/** Deterministic 0–1 from an integer, so a resize keeps the same tear. */
function hash(index: number) {
  const value = Math.sin(index * 78.233 + 0.11) * 43758.5453;
  return value - Math.floor(value);
}

export default function WipeParticles({
  variant = "default",
  direction = "right",
  color = "#9AA3AF",
  sliceSize = 6,
  onRevealed,
}: WipeParticlesProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The parent's callback is read through a ref, assigned in an effect
  // rather than during render, so an inline arrow can't restart the wipe.
  const revealedRef = useRef(onRevealed);
  useEffect(() => {
    revealedRef.current = onRevealed;
  });

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

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

    const horizontal = direction === "right" || direction === "left";

    let width = 0;
    let height = 0;
    let span = 0;
    let cross = 0;
    let slices = 0;
    let emitted: boolean[] = [];

    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);
      span = horizontal ? width : height;
      cross = horizontal ? height : width;
      slices = Math.max(1, Math.ceil(cross / sliceSize));
      emitted = new Array(slices).fill(false);
    };
    resize();

    /**
     * Where the front sits for one slice. Two long waves give the tear
     * its shape; the hash roughens it so neighbouring slices don't line
     * up into a clean ripple.
     */
    const thresholdFor = (slice: number) => {
      const v = slice * sliceSize;
      return (
        Math.sin(v * 0.021 + 0.6) * config.ragged * 0.5 +
        Math.sin(v * 0.058 + 2.2) * config.ragged * 0.3 +
        (hash(slice) - 0.5) * config.ragged * 0.4
      );
    };

    /** Canvas rect covering the un-revealed part of one slice. */
    const drawCover = (slice: number, front: number) => {
      const v = slice * sliceSize;
      const size = Math.min(sliceSize + 0.6, cross - v + 0.6);
      const remaining = Math.max(0, span - front);
      if (remaining <= 0) return;
      if (direction === "right") context.fillRect(front, v, remaining, size);
      else if (direction === "left") context.fillRect(0, v, remaining, size);
      else if (direction === "down") context.fillRect(v, front, size, remaining);
      else context.fillRect(v, 0, size, remaining);
    };

    /** The point on the front where a slice's grains come from. */
    const boundaryPoint = (slice: number, front: number, jitter: number) => {
      const v = slice * sliceSize + jitter * sliceSize;
      if (direction === "right") return { x: front, y: v };
      if (direction === "left") return { x: width - front, y: v };
      if (direction === "down") return { x: v, y: front };
      return { x: v, y: height - front };
    };

    const travel =
      direction === "right"
        ? { x: 1, y: 0 }
        : direction === "left"
          ? { x: -1, y: 0 }
          : direction === "down"
            ? { x: 0, y: 1 }
            : { x: 0, y: -1 };

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

    const grains: Grain[] = [];

    const emit = (slice: number, front: number) => {
      for (let index = 0; index < config.density; index++) {
        const point = boundaryPoint(slice, front, Math.random());
        grains.push({
          x: point.x,
          y: point.y,
          // Mostly carried along with the front, a little across it, and
          // always slower than the front — so the dust is left behind.
          vx: travel.x * config.drift * random(0.2, 0.9) + travel.y * random(-14, 14),
          vy: travel.y * config.drift * random(0.2, 0.9) + travel.x * random(-14, 14),
          age: 0,
          life: random(0.35, 0.7),
          size: config.grain * random(0.6, 1.2),
        });
      }
    };

    // Reduced motion: revealed, with the tear left as a faint dusting
    // where the front finished. The state is the information; the sweep
    // was only how it got there.
    if (reduced) {
      const still = () => {
        context.clearRect(0, 0, width, height);
        context.fillStyle = color;
        for (let slice = 0; slice < slices; slice++) {
          const front = span * 0.94 + thresholdFor(slice);
          for (let index = 0; index < config.density; index++) {
            const point = boundaryPoint(slice, front, Math.random());
            context.globalAlpha = 0.3;
            context.beginPath();
            context.arc(point.x, point.y, config.grain, 0, Math.PI * 2);
            context.fill();
          }
        }
        context.globalAlpha = 1;
      };
      still();
      const onResizeStill = () => {
        resize();
        still();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let elapsed = 0;
    let last = performance.now();
    let announced = false;

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

      // The front runs past both edges by the tear's own amplitude, so
      // no slice is still covered when the clock reaches the end.
      const margin = config.ragged;
      const front = -margin + (elapsed / config.seconds) * (span + margin * 2);

      context.fillStyle = color;
      context.globalAlpha = 0.94;
      let covered = 0;
      for (let slice = 0; slice < slices; slice++) {
        const local = front - thresholdFor(slice);
        if (local < span) covered++;
        drawCover(slice, Math.max(0, local));
        if (!emitted[slice] && local > 0) {
          emitted[slice] = true;
          emit(slice, local);
        }
      }

      // Grains, compacted in place — no per-frame allocation.
      let write = 0;
      for (let index = 0; index < grains.length; index++) {
        const grain = grains[index];
        grain.age += delta;
        if (grain.age >= grain.life) continue;
        grain.x += grain.vx * delta;
        grain.y += grain.vy * delta;
        const fade = 1 - grain.age / grain.life;
        context.globalAlpha = fade * 0.8;
        context.beginPath();
        context.arc(grain.x, grain.y, grain.size * fade, 0, Math.PI * 2);
        context.fill();
        grains[write++] = grain;
      }
      grains.length = write;

      context.globalAlpha = 1;

      if (covered === 0 && grains.length === 0) {
        context.clearRect(0, 0, width, height);
        if (!announced) {
          announced = true;
          revealedRef.current?.();
        }
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      const previous = emitted;
      resize();
      // Keep slices that already fired, so a resize mid-wipe doesn't
      // re-dust ground the front has covered.
      for (let slice = 0; slice < slices; slice++) {
        emitted[slice] = previous[slice] ?? false;
      }
    };
    window.addEventListener("resize", onResize);

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

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

About this effect

A cover crossing a panel and crumbling into its own dust as it goes — for content that arrives all at once and needs a moment of ceremony without a full transition. The front is a threshold on a per-slice field, and the grains are emitted by the crossing itself: a slice spawns dust the instant the front passes its threshold and never again. Because the emission is a consequence of the boundary rather than a second animation aimed at the same place, the dust and the edge cannot drift apart — the failure mode of timing a mask and an emitter separately. Around 160 grains are emitted across a full pass, with fewer than half alive at any moment.

Content arrivingSection transitionLoading to loadedDismissing a placeholder

Related effects