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 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.
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.
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, and one animate() call make up 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 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.
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 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.
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, 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.
<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