GodUIGodUI
129Follow on X

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 keeps a fixed pool of <img> slots instead of mounting a new one on every pointer move. It skips spawns until you've travelled far enough and drives each appearance with element.animate(): direct style writes, with 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 on purpose, and its previous animation is 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 without spawning or animating. 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, and one animate() call make up the whole trail.

Reduced motion

motion-reduce:grid shows a static gallery of the first five images; motion-reduce:hidden hides the live pool. The user sees a row of photos with no trail and no WAAPI.

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 TrailSS: Compositor-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 →