MynduiMyndui
0

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.

01/04A pool, not a stream of mounts

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
tsx
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];
01

A pool, not a stream of mounts

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.

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.

02/04Distance gate before spawn

if (Math.hypot(dx, dy) < threshold) return

Threshold
Math.hypot(dx, dy) < 64 → return early
Spawn
place slot · tilt from atan2(dy, dx)
tsx
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 };
02

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.

03/04WAAPI keyframes, not React state

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)
tsx
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" },);
03

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.

04/04Result

Move your cursor

Images trail the pointer and drift away.

04

The result

Move across the stage — images spawn only after you've travelled, then drift away on their own timeline.

One recycled pool, one distance check, one animate() call — that's the whole trail.

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.

tsx
 
<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>

Motion Score

Image TrailSSCompositor-only
SscaleScale spring / press
SrotateRotation
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →