← All particles

Drag Trail

Particles dropped behind a dragged element, settling where they land.

interactiveplayfulenergetic160 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.

336 lines · react only
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.

Card dragged between columnsMap pin being repositionedReorderable list handleCanvas or whiteboard element

Related effects