← All particles

Sparkle Trail

Small sparkles dropped behind a moving point, fading where they fell.

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

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

/**
 * Vibary · Sparkle Trail
 *
 * Sparkles left behind a moving point. The technique is that they are
 * emitted per unit of distance travelled, not per frame — and when a
 * frame covers more than one spacing, the missing sparkles are placed
 * along the segment rather than piled at its end. So the trail has the
 * same density whether the point crawls or races, and pausing does not
 * dump a heap of sparkles in one spot.
 *
 * The second half of it: a sparkle never moves after it is born. It is
 * left in place and fades, which is what reads as a trail. Drag the
 * sparkles along with the point and you get a comet instead.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props — it traces its own path. Pass `x` and `y` in
 * canvas pixels to drive it from a pointer or an animation.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SparkleTrailProps = {
  /** Visual character of the trail. */
  variant?: "subtle" | "default" | "playful";
  /** Point to follow, in px inside the canvas. Omit to self-trace. */
  x?: number;
  y?: number;
  /** Sparkle colors, sampled per sparkle. */
  colors?: string[];
  /** Draw the moving head. Turn it off when your own cursor is the head. */
  showHead?: boolean;
};

type VariantConfig = {
  /** Px of travel between sparkles. Density, not rate. */
  spacing: number;
  /** Seconds a sparkle stays lit. */
  life: number;
  /** Sparkle radius in px. */
  size: number;
  /** Speed of the built-in path, in laps per second. */
  pace: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A hint that something is happening — a few small points of light.
  subtle: { spacing: 26, life: 0.6, size: 3.2, pace: 0.22 },
  // Reads clearly as a trail without turning into glitter. All-purpose.
  default: { spacing: 18, life: 0.85, size: 4, pace: 0.3 },
  // Denser and a little larger, still countable at a glance.
  playful: { spacing: 13, life: 1.05, size: 5, pace: 0.38 },
};

type Sparkle = {
  x: number;
  y: number;
  age: number;
  life: number;
  size: number;
  tilt: number;
  color: string;
};

const DEFAULT_COLORS = ["#E8B94A", "#E8D9A8", "#C9A8E8"];

/** Hard ceiling on live sparkles, whatever the pointer does. */
const MAX_SPARKLES = 60;

export default function SparkleTrail({
  variant = "default",
  x,
  y,
  colors = DEFAULT_COLORS,
  showHead = true,
}: SparkleTrailProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // The followed point is read through a ref inside one long-lived
  // loop, so a pointer moving every frame never restarts the system.
  const pointRef = useRef<{ x?: number; y?: number }>({ x, y });
  useEffect(() => {
    pointRef.current = { x, y };
  }, [x, y]);

  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;

    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();

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

    /** The built-in path: two unrelated frequencies, so it never repeats obviously. */
    const traced = (elapsed: number) => ({
      x: width * (0.5 + 0.33 * Math.sin(elapsed * config.pace * Math.PI * 2)),
      y: height * (0.5 + 0.26 * Math.sin(elapsed * config.pace * Math.PI * 2 * 1.61 + 0.7)),
    });

    let sparkles: Sparkle[] = [];
    /** Distance travelled since the last sparkle was dropped. */
    let carried = 0;

    const emit = (px: number, py: number) => {
      sparkles.push({
        x: px + random(-2, 2),
        y: py + random(-2, 2),
        age: 0,
        life: config.life * random(0.75, 1.25),
        size: config.size * random(0.65, 1.15),
        tilt: random(0, Math.PI / 2),
        color: colors[Math.floor(Math.random() * colors.length)],
      });
      if (sparkles.length > MAX_SPARKLES) sparkles.shift();
    };

    /** A four-point star with concave sides — the shape of a glint. */
    const drawSpark = (radius: number) => {
      const waist = radius * 0.12;
      context.beginPath();
      context.moveTo(0, -radius);
      context.quadraticCurveTo(waist, -waist, radius, 0);
      context.quadraticCurveTo(waist, waist, 0, radius);
      context.quadraticCurveTo(-waist, waist, -radius, 0);
      context.quadraticCurveTo(-waist, -waist, 0, -radius);
      context.fill();
    };

    const render = (headX: number, headY: number) => {
      context.clearRect(0, 0, width, height);

      for (const sparkle of sparkles) {
        const t = Math.min(1, sparkle.age / sparkle.life);
        // Pops open in the first fifth of its life, then eases out.
        const grow = t < 0.2 ? t / 0.2 : 1 - (t - 0.2) / 0.8;
        if (grow <= 0) continue;
        context.save();
        context.translate(sparkle.x, sparkle.y);
        context.rotate(sparkle.tilt);
        context.globalAlpha = Math.min(1, grow * 1.3) * 0.9;
        context.fillStyle = sparkle.color;
        drawSpark(sparkle.size * (0.4 + grow * 0.6));
        context.restore();
      }

      if (showHead) {
        context.globalAlpha = 0.9;
        context.fillStyle = colors[0];
        context.beginPath();
        context.arc(headX, headY, 2.6, 0, Math.PI * 2);
        context.fill();
        context.globalAlpha = 0.22;
        context.beginPath();
        context.arc(headX, headY, 6.5, 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    // Reduced motion: a still trail. Laid out along the path with the
    // sparkles ageing toward the tail, it says exactly what the effect
    // is without anything moving.
    if (reduced) {
      const drawStill = () => {
        sparkles = [];
        const steps = 9;
        for (let index = 0; index < steps; index++) {
          const point = traced(index * 0.35);
          emit(point.x, point.y);
          const sparkle = sparkles[sparkles.length - 1];
          // Aged along the path: the one at the head sits at its peak,
          // the one at the tail is almost out, so the still frame reads
          // in the same direction the live trail does.
          sparkle.age = sparkle.life * (0.2 + (1 - index / (steps - 1)) * 0.75);
        }
        const head = traced((steps - 1) * 0.35);
        render(head.x, head.y);
      };
      drawStill();
      const onResizeStill = () => {
        resize();
        drawStill();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let last = performance.now();
    let elapsed = 0;
    let previousX: number | null = null;
    let previousY: number | null = null;

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

      const point = pointRef.current;
      const auto = traced(elapsed);
      const headX = point.x ?? auto.x;
      const headY = point.y ?? auto.y;

      if (previousX !== null && previousY !== null) {
        const stepX = headX - previousX;
        const stepY = headY - previousY;
        const travelled = Math.hypot(stepX, stepY);
        carried += travelled;
        // Place the owed sparkles along the segment. Dropping them all
        // at the current position is what makes fast movement clump.
        while (carried >= config.spacing && travelled > 0) {
          const back = (carried - config.spacing) / travelled;
          carried -= config.spacing;
          emit(headX - stepX * Math.min(1, back), headY - stepY * Math.min(1, back));
        }
      }
      previousX = headX;
      previousY = headY;

      for (const sparkle of sparkles) sparkle.age += delta;
      sparkles = sparkles.filter((sparkle) => sparkle.age < sparkle.life);

      render(headX, headY);
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      sparkles = [];
      previousX = null;
      previousY = null;
    };
    window.addEventListener("resize", onResize);

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

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

About this effect

A trail for a cursor, a dragged handle, or the head of a progress sweep. Two decisions keep it from looking like glitter. Sparkles are emitted per unit of distance travelled rather than per frame — and when one frame covers several spacings, the owed sparkles are placed along the segment instead of piled at its end — so the density is the same whether the point crawls or races, and pausing drops nothing. And a sparkle never moves once it is born: it stays where it fell and fades, which is what reads as a trail. Drag them along with the point and you have a comet. About ten are alive at a time, hard-capped at sixty.

Cursor flourishAI enhance actionDragged handle feedbackProgress head

Related effects