Sparkle Trail
Small sparkles dropped behind a moving point, fading where they fell.
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 · 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.