GodUIGodUI
91

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.

Anatomyfixed pool · round-robin recycle

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

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.

The threshold≥ 64px travel between spawns

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 };

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.

The motionWAAPI · no React re-render

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

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>

The result

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

Resultthe real component — move to leave a trail

Move your cursor

Images trail the pointer and drift away.

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

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub