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.
↓Scroll to step through it
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.
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.
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 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.
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];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.
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];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.
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 };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.
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" },);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.
Move your cursor
Images trail the pointer and drift away.
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.
<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
scaleScale spring / pressrotateRotationopacityFade / cross-fade