Line Draw Particles
A line written by a moving head, made of the grains it settles behind it.
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 · Line Draw Particles
*
* A line drawn by a moving head, made of the grains it leaves behind.
*
* The technique: the line's width is a consequence of its age. A grain
* is laid at the nib with a small sideways velocity and is stopped by
* one damping constant, so the spread it has accumulated by age t is
* (v/k)·(1 − e^(−kt)) — a hairline where it was just laid, and its full
* grainy width a fixed time later. Because the head is moving, that time
* is a distance: the stroke tapers from sharp to soft over speed × 3/k
* behind the nib, which is 50px at the slow setting and 150px at the
* fast one. Nothing in the file tunes a taper; it falls out of the same
* two numbers that set the width and the speed, and a stroke that
* sharpens at the tip is the tell that a line is being written rather
* than revealed.
*
* The head travels by arc length rather than by curve parameter, so it
* moves at one speed through the straights and the corners alike.
* Stepping a spline by its parameter makes the nib race down the flats
* and crawl round the bends, and the grain density inherits the error.
*
* Grains never move again once they have settled, and they never fade:
* they are the line, not a trail over one. Emission is per unit of
* distance travelled, so the line has the same density however fast it
* is drawn.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `points`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type LineDrawParticlesProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The series, as [x, y] in 0–1. y is measured up from the bottom. */
points?: [number, number][];
/** Line colour. Defaults to the inherited text colour. */
color?: string;
/** Seconds of stillness before the head sets off. */
delay?: number;
/** Fires once the last grain has settled. */
onDrawn?: () => void;
};
type VariantConfig = {
/** Head speed along the path, px per second. */
speed: number;
/** Settled half-width of the stroke, in px. */
spread: number;
/** Damping, per second. The reciprocal is how long a grain rolls for. */
settle: number;
/** Grain radius in px. */
dot: number;
/** Px of travel between grains. */
step: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A slow, fine nib: a tight line with a short taper.
subtle: { speed: 150, spread: 2.2, settle: 9, dot: 1.05, step: 2.6 },
// Reads as drawn rather than revealed. All-purpose.
default: { speed: 215, spread: 3.4, settle: 7.5, dot: 1.2, step: 2.2 },
// A quicker hand and a looser nib, so the taper runs much further.
playful: { speed: 300, spread: 5, settle: 6, dot: 1.35, step: 1.9 },
};
const DEFAULT_POINTS: [number, number][] = [
[0, 0.2],
[0.17, 0.33],
[0.33, 0.26],
[0.5, 0.5],
[0.66, 0.43],
[0.83, 0.68],
[1, 0.88],
];
type Grain = {
x: number;
y: number;
vx: number;
vy: number;
tone: number;
moving: boolean;
};
export default function LineDrawParticles({
variant = "default",
points = DEFAULT_POINTS,
color,
delay = 0.25,
onDrawn,
}: LineDrawParticlesProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref, assigned in an effect
// rather than during render.
const drawnRef = useRef(onDrawn);
useEffect(() => {
drawnRef.current = onDrawn;
}, [onDrawn]);
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 ink = color ?? getComputedStyle(canvas).color ?? "#888888";
/** Sideways speed a grain is laid with, solved from the width it ends at. */
const launch = config.spread * config.settle;
let width = 0;
let height = 0;
let path: { x: number; y: number; at: number }[] = [];
let pathLength = 0;
let grains: Grain[] = [];
const random = (min: number, max: number) => min + Math.random() * (max - min);
const build = () => {
const pad = Math.min(14, width * 0.05);
const boxWidth = Math.max(1, width - pad * 2);
const boxHeight = Math.max(1, height - pad * 2);
const anchors = points.map(([px, py]) => ({
x: pad + px * boxWidth,
y: pad + (1 - py) * boxHeight,
}));
// Catmull-Rom through the anchors, then an arc-length table so the
// head can travel at a constant speed rather than a constant
// parameter — which is what keeps the grain density even.
const dense: { x: number; y: number }[] = [];
const at = (index: number) =>
anchors[Math.max(0, Math.min(anchors.length - 1, index))];
for (let index = 0; index + 1 < anchors.length; index++) {
const p0 = at(index - 1);
const p1 = at(index);
const p2 = at(index + 1);
const p3 = at(index + 2);
for (let step = 0; step < 16; step++) {
const t = step / 16;
const t2 = t * t;
const t3 = t2 * t;
dense.push({
x:
0.5 *
(2 * p1.x +
(-p0.x + p2.x) * t +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
y:
0.5 *
(2 * p1.y +
(-p0.y + p2.y) * t +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
});
}
}
dense.push(anchors[anchors.length - 1]);
path = [];
let travelled = 0;
for (let index = 0; index < dense.length; index++) {
if (index > 0) {
travelled += Math.hypot(
dense[index].x - dense[index - 1].x,
dense[index].y - dense[index - 1].y
);
}
path.push({ x: dense[index].x, y: dense[index].y, at: travelled });
}
pathLength = travelled;
grains = [];
};
const layout = () => {
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);
build();
};
layout();
/** Position and heading a given distance along the path. */
const along = (distance: number) => {
const target = Math.max(0, Math.min(pathLength, distance));
let low = 0;
let high = path.length - 1;
while (low < high - 1) {
const middle = (low + high) >> 1;
if (path[middle].at <= target) low = middle;
else high = middle;
}
const a = path[low];
const b = path[high];
const span = Math.max(1e-4, b.at - a.at);
const t = (target - a.at) / span;
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
return {
x: a.x + dx * t,
y: a.y + dy * t,
nx: -dy / length,
ny: dx / length,
};
};
const lay = (distance: number) => {
const point = along(distance);
// Mostly across the stroke, a little along it — the nib has a
// width, not a direction.
const angle = Math.atan2(point.ny, point.nx) + random(-0.55, 0.55);
const push = launch * random(0.22, 1) * (Math.random() < 0.5 ? -1 : 1);
grains.push({
x: point.x,
y: point.y,
vx: Math.cos(angle) * push,
vy: Math.sin(angle) * push,
tone: Math.random(),
moving: true,
});
};
const render = (headDistance: number, drawing: boolean) => {
context.clearRect(0, 0, width, height);
context.fillStyle = ink;
for (const grain of grains) {
context.globalAlpha = 0.42 + grain.tone * 0.42;
context.beginPath();
context.arc(grain.x, grain.y, config.dot * (0.75 + grain.tone * 0.5), 0, Math.PI * 2);
context.fill();
}
if (drawing) {
const head = along(headDistance);
context.globalAlpha = 0.95;
context.shadowColor = ink;
context.shadowBlur = config.dot * 5;
context.beginPath();
context.arc(head.x, head.y, config.dot * 1.9, 0, Math.PI * 2);
context.fill();
context.shadowBlur = 0;
}
context.globalAlpha = 1;
};
// Reduced motion: the finished line. A reveal that never completes
// is a bug rather than an accessible variant, so the grains are laid
// and settled analytically — each one placed at exactly the offset
// its damping would have carried it to — and drawn once.
if (reduced) {
const still = () => {
grains = [];
for (let distance = 0; distance <= pathLength; distance += config.step) {
lay(distance);
}
for (const grain of grains) {
grain.x += grain.vx / config.settle;
grain.y += grain.vy / config.settle;
grain.vx = 0;
grain.vy = 0;
grain.moving = false;
}
render(pathLength, false);
};
still();
drawnRef.current?.();
const onResizeStill = () => {
layout();
still();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let travelled = 0;
let laidTo = 0;
let announced = false;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
const drawing = elapsed > delay && travelled < pathLength;
if (drawing) {
travelled = Math.min(pathLength, travelled + config.speed * delta);
// Per unit of distance, so the density is the same at any speed
// and a frame that covers several steps lays all of them.
while (laidTo <= travelled) {
lay(laidTo);
laidTo += config.step;
}
}
let moving = false;
for (const grain of grains) {
if (!grain.moving) continue;
grain.x += grain.vx * delta;
grain.y += grain.vy * delta;
const hold = Math.exp(-config.settle * delta);
grain.vx *= hold;
grain.vy *= hold;
if (Math.hypot(grain.vx, grain.vy) < 1.5) grain.moving = false;
else moving = true;
}
render(travelled, elapsed > delay && travelled < pathLength);
if (travelled >= pathLength && !moving) {
if (!announced) {
announced = true;
drawnRef.current?.();
}
// Drawn and settled. There is nothing left to integrate, so the
// loop ends rather than repainting a finished picture.
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
layout();
elapsed = 0;
travelled = 0;
laidTo = 0;
announced = false;
cancelAnimationFrame(frame);
last = performance.now();
frame = requestAnimationFrame(tick);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, points, color, delay]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
For a figure that should arrive rather than appear — a sparkline on a metric card, a trend on a report, a route being traced. The line's width is a consequence of its age: a grain is laid at the nib with a small sideways velocity and stopped by one damping constant, so the spread it has by a given moment is a hairline where it was just laid and its full grainy width a fixed time later. Because the head is moving, that time is a distance, and the stroke tapers from sharp to soft over a run behind the nib that measures 50px at the slow setting and 150px at the fast one. Nothing tunes a taper; it falls out of the same two numbers that set the width and the speed, and a stroke that sharpens at its tip is the tell that a line is being written rather than uncovered. The head travels by arc length rather than by curve parameter, so it holds one speed through the straights and the corners alike — stepping a spline by its parameter makes the nib race down the flats and crawl round the bends, and the grain density inherits the error. Grains never move again once settled and never fade: they are the line, not a trail over one.