Anatomy of Image Trail
How a recycled DOM pool, a travel threshold, and the Web Animations API leave a trail of images without a React re-render per move.
ImageTrail doesn't mount a new <img> every time the pointer moves. It
keeps a fixed pool of slots, skips spawns until you've travelled far enough,
and drives each appearance with element.animate() — direct style writes,
zero React updates on the hot path.
A pool, not a stream of mounts
On render, max (default 12) invisible <img> nodes are created once and
stored in slots.current. Each spawn takes slots[slotIndex % max], bumps
the index, swaps src from the images array, and animates. When the pool
wraps, the oldest slot is reused mid-flight — that's intentional: the
previous animation is simply overwritten.
const img = slots.current[slotIndex.current % max]
- Pool slots
- Array.from({ length: max }) · refs into slots.current
- Active write
- slots[slotIndex % max] · round-robin reuse
const slots = React.useRef<(HTMLImageElement | null)[]>([]);
const slotIndex = React.useRef(0);
const img = slots.current[slotIndex.current % max];
slotIndex.current += 1;
img.src = images[imageIndex.current % images.length];Distance gate before spawn
onPointerMove converts the event to container-local coordinates and
compares against last. If Math.hypot(dx, dy) is under threshold
(default 64px), the handler returns — no spawn, no animation. Past the
threshold, atan2(dy, dx) becomes a clamped tilt (±12°) so each image leans
slightly into the direction of travel.
if (Math.hypot(dx, dy) < threshold) return
- Threshold
- Math.hypot(dx, dy) < 64 → return early
- Spawn
- place slot · tilt from atan2(dy, dx)
const dx = x - prev.x;
const dy = y - prev.y;
if (Math.hypot(dx, dy) < threshold) return;
spawn(x, y, Math.atan2(dy, dx));
last.current = { x, y };WAAPI keyframes, not React state
spawn sets left/top, then calls img.animate with three keyframes:
fade/scale in to full size at 18%, then fade out while drifting slightly
upward (translate(-50%, -65%)) and settling at scale(0.92). Easing is
cubic-bezier(0.22, 1, 0.36, 1) with fill: "forwards". Nothing in that
path calls setState.
img.animate([…], { duration, easing: "cubic-bezier(0.22,1,0.36,1)" })
- Scale
- 0.4 → 1 (at 18%) → 0.92
- Opacity
- 0 → 1 → 0 · fill: forwards
- Drift + tilt
- −50% → −65% Y · rotate(tilt)
img.animate(
[
{ opacity: 0, transform: `translate(-50%, -50%) scale(0.4) rotate(${tilt}deg)` },
{ opacity: 1, transform: `translate(-50%, -50%) scale(1) rotate(${tilt}deg)`, offset: 0.18 },
{ opacity: 0, transform: `translate(-50%, -65%) scale(0.92) rotate(${tilt}deg)` },
],
{ duration, easing: "cubic-bezier(0.22,1,0.36,1)", fill: "forwards" },
);Reduced motion
motion-reduce:grid reveals a calm static gallery of the first five images;
motion-reduce:hidden hides the live pool. No trail, no WAAPI — just a quiet
row of photos.
<div className="… hidden place-items-center motion-reduce:grid">
{images.slice(0, 5).map((src, i) => (
<img key={i} src={src} alt="" className="size-24 rounded-lg …" />
))}
</div>
<div className="… motion-reduce:hidden">{/* pool */}</div>The result
Move across the stage — images spawn only after you've travelled, then drift away on their own timeline.
Move your cursor
Images trail the pointer and drift away.
One recycled pool, one distance check, one animate() call — that's the
whole trail.