Drag Trail
Particles dropped behind a dragged element, settling where they land.
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 · Drag Trail
*
* Particles left behind something being dragged, which come to rest
* where they land rather than fading out mid-air.
*
* The technique that makes it a trail: particles are emitted per pixel
* travelled, not per second. A time-based emitter is a fountain — it
* keeps pouring while the thing is held still, and it clumps at the
* start of a flick and thins in the middle. Distance-based emission
* gives an even trail at any speed, produces nothing at all when the
* drag pauses, and — because the emission points are interpolated along
* the segment between frames — stays even through a fast throw that
* covers half the screen in one frame.
*
* Drop it inside any positioned element. Left alone it follows the
* pointer on that parent; pass `point` and it follows whatever you are
* moving, so it works with a keyboard-driven control too.
*
* 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 DragTrailProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/**
* Where the dragged thing is, in px inside the parent's box, or
* `null` while nothing is being dragged. Leave it undefined and the
* component follows the pointer on the parent by itself.
*/
point?: { x: number; y: number } | null;
/** Most particles alive at once. Older ones are recycled past this. */
count?: number;
/** Particle fill. */
color?: string;
/** Fires once the last particle of a trail has faded. */
onTrailSettled?: () => void;
};
type VariantConfig = {
/** Pixels of travel between two particles. This is the density dial. */
spacing: number;
/** Fraction of the drag's own velocity each particle inherits. */
inherit: number;
/** Extra scatter at birth, in px per second. */
spread: number;
/** Seconds from dropped to gone. */
life: number;
/** Particle radius in px. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A thin dotted line that barely outlives the drag.
subtle: { spacing: 16, inherit: 0.1, spread: 8, life: 0.85, dot: 1.6 },
// Reads as a trail without covering the thing being dragged.
default: { spacing: 11, inherit: 0.16, spread: 14, life: 1.1, dot: 2 },
// Denser, thrown further, and it lingers.
playful: { spacing: 7, inherit: 0.24, spread: 26, life: 1.4, dot: 2.4 },
};
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
age: number;
life: number;
size: number;
};
/** Drag constant. High enough that a particle settles well inside its life. */
const DRAG = 6.5;
/** Ceiling on inherited speed, so a jump in the input can't fling anything. */
const MAX_INHERIT = 2600;
/** Seconds of stillness after which the loop parks itself. */
const IDLE_STOP = 0.5;
export default function DragTrail({
variant = "default",
point,
count = 160,
color = "#7C8CF0",
onTrailSettled,
}: DragTrailProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wakeRef = useRef<(() => void) | null>(null);
// The position is read through a ref and the loop is woken by hand,
// so a parent re-rendering at 60fps never rebuilds anything.
const pointRef = useRef(point);
useEffect(() => {
pointRef.current = point;
wakeRef.current?.();
}, [point]);
const settledRef = useRef(onTrailSettled);
useEffect(() => {
settledRef.current = onTrailSettled;
}, [onTrailSettled]);
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 pool = Math.max(12, count);
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Decided once: a parent that passes a position owns the position.
const controlled = pointRef.current !== undefined;
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[] = [];
let pointer: { x: number; y: number } | null = null;
/** Raw client coords, converted to canvas space once per frame. */
let pointerClientX = 0;
let pointerClientY = 0;
let pointerSeen = false;
let dragging = false;
let previous: { x: number; y: number } | null = null;
let carry = 0;
let still = 0;
let frame = 0;
let running = false;
let last = performance.now();
const emit = (x: number, y: number, vx: number, vy: number) => {
const scatter = () => (Math.random() - 0.5) * 2 * config.spread;
particles.push({
x,
y,
// Reduced motion: the particle is laid down exactly on the path
// and only fades. The trail still records where the drag went,
// which is the whole message.
vx: reduced ? 0 : vx * config.inherit + scatter(),
vy: reduced ? 0 : vy * config.inherit + scatter(),
age: 0,
life: config.life * (0.8 + Math.random() * 0.4),
size: config.dot * (0.7 + Math.random() * 0.6),
});
if (particles.length > pool) particles.splice(0, particles.length - pool);
};
const draw = () => {
context.clearRect(0, 0, width, height);
context.fillStyle = color;
for (const particle of particles) {
const t = particle.age / particle.life;
// Held at full for the first stretch, then let go — so the trail
// has body behind the cursor before it starts to disappear.
const fade =
Math.min(1, particle.age / 0.06) * (t < 0.45 ? 1 : 1 - (t - 0.45) / 0.55);
if (fade <= 0.01) continue;
context.globalAlpha = Math.min(0.8, fade);
context.beginPath();
context.arc(particle.x, particle.y, particle.size * (0.75 + 0.25 * (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, not one per pointer event: the handlers
// only record client coordinates, so a 120Hz drag on a page with
// live layout never forces synchronous layout per event.
if (pointerSeen) {
pointerSeen = false;
const rect = canvas.getBoundingClientRect();
pointer = { x: pointerClientX - rect.left, y: pointerClientY - rect.top };
}
const current = controlled ? (pointRef.current ?? null) : pointer;
if (current) {
if (previous) {
const dx = current.x - previous.x;
const dy = current.y - previous.y;
const distance = Math.hypot(dx, dy);
if (distance > 0.01) {
still = 0;
let speedX = dx / delta;
let speedY = dy / delta;
const speed = Math.hypot(speedX, speedY);
if (speed > MAX_INHERIT) {
speedX = (speedX / speed) * MAX_INHERIT;
speedY = (speedY / speed) * MAX_INHERIT;
}
// Walk the segment and drop a particle every `spacing` px,
// carrying the remainder into the next frame. This is what
// keeps the spacing even through a fast throw.
let remaining = distance;
while (carry + remaining >= config.spacing) {
const need = config.spacing - carry;
const at = (distance - remaining + need) / distance;
emit(previous.x + dx * at, previous.y + dy * at, speedX, speedY);
remaining -= need;
carry = 0;
}
carry += remaining;
} else {
still += delta;
}
}
previous = { x: current.x, y: current.y };
} else {
// Released: forget where it was, so the next drag does not draw
// a streak across the gap between them.
previous = null;
carry = 0;
still += delta;
}
const decay = Math.exp(-DRAG * delta);
for (const particle of particles) {
particle.age += delta;
// Exact integral of the drag across the frame: each particle
// slides a little past where it was dropped, then rests there.
particle.x += (particle.vx * (1 - decay)) / DRAG;
particle.y += (particle.vy * (1 - decay)) / DRAG;
particle.vx *= decay;
particle.vy *= decay;
}
const before = particles.length;
particles = particles.filter((particle) => particle.age < particle.life);
if (before > 0 && particles.length === 0) settledRef.current?.();
draw();
if (particles.length === 0 && still > IDLE_STOP) {
// Nothing moving and nothing left: park the loop until the next
// move wakes it.
running = false;
frame = 0;
return;
}
frame = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
// A wake after a pause is a new gesture, not a continuation.
previous = null;
carry = 0;
still = 0;
last = performance.now();
frame = requestAnimationFrame(tick);
};
wakeRef.current = start;
// The handlers deliberately do no layout work and no canvas-space
// math: they store the event and let the frame loop convert it.
const onPointerDown = (event: PointerEvent) => {
dragging = true;
pointerClientX = event.clientX;
pointerClientY = event.clientY;
pointerSeen = true;
surface.setPointerCapture?.(event.pointerId);
start();
};
const onPointerMove = (event: PointerEvent) => {
if (!dragging) return;
pointerClientX = event.clientX;
pointerClientY = event.clientY;
pointerSeen = true;
// A drag that paused long enough to park the loop starts it again.
start();
};
const onPointerUp = () => {
dragging = false;
pointerSeen = false;
pointer = null;
};
if (!controlled) {
surface.addEventListener("pointerdown", onPointerDown);
surface.addEventListener("pointermove", onPointerMove);
surface.addEventListener("pointerup", onPointerUp);
surface.addEventListener("pointercancel", onPointerUp);
}
window.addEventListener("resize", resize);
return () => {
cancelAnimationFrame(frame);
wakeRef.current = null;
if (!controlled) {
surface.removeEventListener("pointerdown", onPointerDown);
surface.removeEventListener("pointermove", onPointerMove);
surface.removeEventListener("pointerup", onPointerUp);
surface.removeEventListener("pointercancel", onPointerUp);
}
window.removeEventListener("resize", resize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
pointerEvents: "none",
}}
/>
);
}About this effect
Weight and history for something being moved — a card between columns, a pin on a map, a slider being thrown. Particles are emitted per pixel travelled rather than per second, which is the difference between a trail and a fountain: a time-based emitter keeps pouring while the thing is held still and clumps at the start of a flick, while distance-based emission gives an even line at any speed, produces nothing at all during a pause, and stays even through a throw that crosses the surface in a single frame because the drop points are interpolated along the segment. Each particle inherits a fraction of the drag's velocity, slides a little past where it was dropped and comes to rest there rather than fading out mid-air. Left alone it follows the pointer on its parent; give it a position and it will follow a keyboard-driven control just as well.