Wipe Particles
A directional wipe with a torn, grainy edge instead of a hard line.
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.
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.