Path Follow Trail
A chain of points following a drawn path, over a trail that records how fast you drew 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 · Path Follow Trail
*
* A short chain of points following the path you draw, over a trail that
* records how fast you drew it.
*
* The technique: the lag is a distance, not a time. The pointer's path
* is resampled into a buffer of samples spaced a fixed number of pixels
* apart, and follower k simply sits k·spacing back along it — so a
* position in the buffer *is* a distance, and the chain holds its
* formation at any speed. The usual approach, easing each follower
* toward the one ahead of it, cannot: in simulation the same chain
* closes to 3.5px gaps at 60px/s and stretches to 53px at 900px/s, a
* fifteen-fold difference, while the resampled one measures 26.0px at
* both. It is also why stopping leaves the chain strung out along the
* path where you left it, instead of collapsing onto your finger.
*
* The trail is the same buffer read a second way. Each sample carries
* the speed it was laid down at, and is drawn as a dash exactly as long
* as the head travelled in one frame there. So the trail is dotted where
* you were slow and closes into a solid line above about 240px/s, with
* nothing to tune: the dash length and the gap between samples are the
* two numbers, and the crossover is their ratio.
*
* Distances are fractional: the head rides the pointer itself and each
* follower interpolates between the two samples its distance falls
* across. Snapping to whole samples would move every dot on a 4px grid,
* which reads as stutter exactly when the drawing is slow and careful.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `spacing`, `followers`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PathFollowTrailProps = {
/** Visual character of the response. */
variant?: "subtle" | "default" | "playful";
/** Points in the chain. */
followers?: number;
/** Distance between neighbours in the chain, in px. */
spacing?: number;
/** Colour. Defaults to the inherited text colour. */
color?: string;
/** After this long without input, draw a path. 0 disables. */
idleDemoSeconds?: number;
};
type VariantConfig = {
/** Points in the chain. */
followers: number;
/** Px between them along the path. */
spacing: number;
/** Radius of the head, in px. Followers taper from it. */
dot: number;
/** Samples of path kept as trail — its length is this times STEP. */
trail: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short chain and a brief trail: a hint of a gesture.
subtle: { followers: 5, spacing: 34, dot: 2.4, trail: 140 },
// Enough chain to read as a body, enough trail to read the speed.
default: { followers: 8, spacing: 26, dot: 2.8, trail: 190 },
// A long chain packed close, over a trail that remembers further.
playful: { followers: 12, spacing: 20, dot: 3.2, trail: 250 },
};
/** Distance between path samples, in px. Half of everything below. */
const STEP = 4;
/** Seconds for an abandoned gesture to fade out. */
const FADE = 1.4;
/** Dash length is capped here, in multiples of STEP, so a flick stays a line. */
const DASH_CAP = 4;
export default function PathFollowTrail({
variant = "default",
followers,
spacing,
color,
idleDemoSeconds = 0,
}: PathFollowTrailProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const chain = Math.max(2, Math.round(followers ?? config.followers));
const gap = Math.max(8, spacing ?? config.spacing);
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ink = color ?? getComputedStyle(canvas).color ?? "#888888";
// The buffer has to reach past the end of the chain as well as the
// end of the visible trail, because the chain is read out of it too.
const back = Math.ceil((chain * gap) / STEP);
const size = config.trail + back + 8;
const sampleX = new Float32Array(size);
const sampleY = new Float32Array(size);
const sampleSpeed = new Float32Array(size);
let head = -1;
let filled = 0;
let width = 0;
let height = 0;
let lastX = 0;
let lastY = 0;
let hasPath = false;
let speed = 0;
/** 1 while a gesture is live, decaying to 0 once it is abandoned. */
let presence = 0;
let pointerX = 0;
let pointerY = 0;
let pointerLive = false;
/** Where the head actually is — px beyond the newest sample. */
let tipX = 0;
let tipY = 0;
let remainder = 0;
/** Raw client coords, converted to canvas space once per frame. */
let pointerClientX = 0;
let pointerClientY = 0;
let pointerSeen = false;
const resize = () => {
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);
context.lineCap = "round";
};
resize();
const push = (x: number, y: number, at: number) => {
head = (head + 1) % size;
sampleX[head] = x;
sampleY[head] = y;
sampleSpeed[head] = at;
if (filled < size) filled++;
};
const indexBack = (steps: number) =>
steps < filled ? (head - steps + size * 2) % size : -1;
/**
* Lay samples along the segment just travelled, one every STEP px.
* Doing it here rather than once per frame is what makes a position
* in the buffer mean a distance — including through a flick that
* crosses the surface between two frames.
*/
const extend = (x: number, y: number, moveSpeed: number) => {
if (!hasPath) {
lastX = x;
lastY = y;
hasPath = true;
push(x, y, moveSpeed);
} else {
let travelled = Math.hypot(x - lastX, y - lastY);
let guard = 0;
while (travelled >= STEP && guard++ < size) {
const t = STEP / travelled;
lastX += (x - lastX) * t;
lastY += (y - lastY) * t;
push(lastX, lastY, moveSpeed);
travelled = Math.hypot(x - lastX, y - lastY);
}
}
tipX = x;
tipY = y;
remainder = Math.hypot(x - lastX, y - lastY);
};
const render = () => {
context.clearRect(0, 0, width, height);
if (filled < 2 || presence <= 0.01) return;
const trailLength = Math.min(config.trail, filled - 1);
// Trail, in five age bands so the fade costs five strokes rather
// than one per dash.
const bands = 5;
context.strokeStyle = ink;
context.lineWidth = 1.6;
for (let band = 0; band < bands; band++) {
const from = Math.floor((band / bands) * trailLength);
const to = Math.floor(((band + 1) / bands) * trailLength);
const age = (band + 0.5) / bands;
context.globalAlpha = presence * 0.5 * (1 - age) ** 1.6;
context.beginPath();
for (let step = from; step < to; step++) {
const index = indexBack(step);
const prior = indexBack(step + 1);
if (index < 0 || prior < 0) continue;
const dx = sampleX[index] - sampleX[prior];
const dy = sampleY[index] - sampleY[prior];
const span = Math.hypot(dx, dy) || 1;
// Exactly the distance the head covered in one frame here: the
// dash is the speed, with nothing converting between them.
const dash = Math.min(
STEP * DASH_CAP,
Math.max(0.8, sampleSpeed[index] / 60)
);
const ux = (dx / span) * dash * 0.5;
const uy = (dy / span) * dash * 0.5;
context.moveTo(sampleX[index] - ux, sampleY[index] - uy);
context.lineTo(sampleX[index] + ux, sampleY[index] + uy);
}
context.stroke();
}
// Chain. Follower k is k·gap back along the path, full stop — and
// the fraction of that distance falling between two samples is
// interpolated, so nothing in the chain moves on the sample grid.
context.fillStyle = ink;
for (let k = chain; k >= 1; k--) {
const stepsBack = (k * gap - remainder) / STEP;
const whole = Math.floor(stepsBack);
const part = stepsBack - whole;
const newer = indexBack(whole);
const older = indexBack(whole + 1);
if (newer < 0 || older < 0) continue;
const x = sampleX[newer] + (sampleX[older] - sampleX[newer]) * part;
const y = sampleY[newer] + (sampleY[older] - sampleY[newer]) * part;
const taper = 1 - (k / (chain + 1)) * 0.72;
context.globalAlpha = presence * (0.2 + taper * 0.55);
context.beginPath();
context.arc(x, y, config.dot * taper, 0, Math.PI * 2);
context.fill();
}
// The head is the pointer, not the nearest sample behind it.
if (hasPath) {
context.globalAlpha = presence * 0.92;
context.beginPath();
context.arc(tipX, tipY, config.dot, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
/** The self-drawn gesture: a figure eight taken at an uneven pace. */
const demoPoint = (time: number) => {
const turn = time * 0.9 + Math.sin(time * 1.8) * 0.75;
return {
x: width * (0.5 + 0.33 * Math.sin(turn)),
y: height * (0.5 + 0.3 * Math.sin(turn) * Math.cos(turn)),
};
};
// Reduced motion: one gesture, already drawn. The chain strung out
// along it and the trail dotted where the pace was slow are both
// readable in a single frame — they are records, not movement.
if (reduced) {
const still = () => {
head = -1;
filled = 0;
hasPath = false;
presence = 1;
let previous = demoPoint(0);
for (let step = 1; step <= 260; step++) {
const time = step * (1 / 60);
const point = demoPoint(time);
extend(
point.x,
point.y,
Math.hypot(point.x - previous.x, point.y - previous.y) * 60
);
previous = point;
}
render();
};
still();
const onResizeStill = () => {
resize();
still();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let sleeping = false;
let last = performance.now();
let lastInput = performance.now();
let demoTime = 0;
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 handler
// only records client coordinates, so a 120Hz pointer on a page
// with live layout never forces synchronous layout per event.
if (pointerSeen) {
pointerSeen = false;
const rect = canvas.getBoundingClientRect();
const x = pointerClientX - rect.left;
const y = pointerClientY - rect.top;
if (x >= 0 && y >= 0 && x <= width && y <= height) {
if (!pointerLive) {
// A new gesture starts here rather than continuing the old one.
head = -1;
filled = 0;
hasPath = false;
speed = 0;
}
pointerLive = true;
pointerX = x;
pointerY = y;
lastInput = pointerEventAt;
} else {
pointerLive = false;
}
}
// A pointer that has stopped moving is not drawing. Ending the
// gesture here is what lets the trail fade and the loop park, and
// it means the next movement starts a fresh path rather than
// joining itself to a stale one.
if (pointerLive && now - lastInput > 650) pointerLive = false;
const idling =
idleDemoSeconds > 0 && !pointerLive && now - lastInput > idleDemoSeconds * 1000;
if (idling) {
const before = demoPoint(demoTime);
demoTime += delta;
const point = demoPoint(demoTime);
speed = Math.hypot(point.x - before.x, point.y - before.y) / Math.max(delta, 1e-4);
extend(point.x, point.y, speed);
presence = Math.min(1, presence + delta * 4);
} else if (pointerLive) {
extend(pointerX, pointerY, speed);
presence = Math.min(1, presence + delta * 6);
} else {
presence = Math.max(0, presence - delta / FADE);
}
render();
// Nothing on screen and nothing being drawn — park the loop until
// the next pointer event. Only the optional self-tour has a reason
// to keep running; without that exception it would park on its
// first frame, before the tour has had a chance to start.
if (presence <= 0.001 && !pointerLive && idleDemoSeconds <= 0) {
head = -1;
filled = 0;
hasPath = false;
sleeping = true;
return;
}
frame = requestAnimationFrame(tick);
};
const wake = () => {
if (!sleeping) return;
sleeping = false;
last = performance.now();
frame = requestAnimationFrame(tick);
};
let previousX = 0;
let previousY = 0;
let previousTime = 0;
let pointerEventAt = 0;
// Deliberately does no layout work and no canvas-space math: it
// stores the event and lets the frame loop convert it. Speed only
// needs deltas, and those are the same in client space.
const onPointerMove = (event: PointerEvent) => {
const now = performance.now();
const elapsed = (now - previousTime) / 1000;
const moved = Math.hypot(event.clientX - previousX, event.clientY - previousY);
if (elapsed > 0.2) {
// A gap this long is a new gesture, not a fast segment.
speed = 0;
} else {
// Smoothed a little, or one jittery event makes a long dash.
speed += (moved / Math.max(0.004, elapsed) - speed) * 0.45;
}
previousX = event.clientX;
previousY = event.clientY;
previousTime = now;
pointerClientX = event.clientX;
pointerClientY = event.clientY;
pointerEventAt = now;
pointerSeen = true;
wake();
};
const onPointerLeave = () => {
pointerSeen = false;
pointerLive = false;
wake();
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerMove, { passive: true });
document.addEventListener("pointerleave", onPointerLeave);
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
head = -1;
filled = 0;
hasPath = false;
wake();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerMove);
document.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("resize", onResize);
};
}, [variant, followers, spacing, color, idleDemoSeconds]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
For a surface where the gesture itself is the content — a signature field, a drawing canvas, a swipe tutorial, a cursor that should feel attached to something. The lag is a distance rather than a time: the path is resampled into samples a fixed number of pixels apart, so follower k simply sits k spacings back along it and the chain holds its formation at any speed. Easing each follower toward the one ahead cannot do that — in simulation the same chain closes to 3.5px gaps at 60px/s and stretches to 53px at 900px/s, fifteen times the difference, where the resampled one measures 26.0px at both. It is also why stopping leaves the chain strung out along the path rather than collapsing onto your finger. The trail is the same buffer read a second way: each sample carries the speed it was laid at and is drawn as a dash exactly as long as the head travelled in one frame there, so the trail is dotted where you were slow and closes into a solid line above about 240px per second, with nothing to tune — the crossover is just the ratio of the dash to the sample spacing.