Pooled Cursor Image Trail
Sweeping the pointer across the panel drops a trail of tiles behind it, each fading up over 220ms, holding, and fading out — total life 520ms. The tiles come from a fixed ring of 22 nodes created once at mount and reused by index, and a new one is only released when the pointer has travelled more than 130px since the last. Those two rules are the component; the tween values are decoration.
Pooled Cursor Image Trail
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/pooled-image-trail.jsonAlso installs prng from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content rendered above the trail layer. |
| threshold | number | = 130 | Pixels of pointer travel required before another tile is released. |
| poolSize | number | = 22 | Number of reusable nodes in the ring. |
| plateCount | number | = 17 | Distinct plates painted at mount. Keep coprime with poolSize. |
| tileWidth | number | = 236 | Tile width in CSS px. |
| tileHeight | number | = 160 | Tile height in CSS px. |
| seed | string | = 'pooled-image-trail' | Seeds plate choice and tilt so the sequence is deterministic. |
| className | string | — | Merged onto the root container via cn. |
How it works
The technique in order, not the code in order.
A ring of
poolSizeabsolutely-positioned canvases is rendered once, all withwill-change: transformso layer promotion has already happened before the first spawn. Nothing is ever created or removed after mount.plateCountgradient plates are painted into offscreen canvases at mount — the registry ships no image assets, so the "photographs" are drawn. A spawn is then a singledrawImageblit, which keeps the gradient and text work off the frame where a tween starts.On
pointermovethe handler measuresMath.hypot(x - anchor.x, y - anchor.y)against the last spawn point and returns early belowthreshold. The very first move only seeds the anchor and spawns nothing, so entering from an edge does not drop a tile before any real movement has happened.A spawn takes slot
spawned % poolSize, picks a plate with a seeded RNG plus a one-line no-repeat guard (if (i === last) i = (i + 1) % n), blits it, thengsap.killTweensOf(node)beforegsap.setreinitialises x, y, rotation, scale 0.92, opacity 0 andzIndex: spawned.zIndexis the monotonically increasing spawn counter, so the newest tile always paints over older ones with no stacking logic at all. At ten spawns a second it would take roughly 285,000 years to reachNumber.MAX_SAFE_INTEGER.The life is one timeline: in over 220ms with
power2.out, hold 100ms, out over 200ms withpower2.in. The pool is sized so a slot's 520ms life always expires before the ring wraps back around to it.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| threshold | = 130 | Pixels of travel per tile, and the single most load-bearing number here. A deliberate sweep across a hero yields 8-10 readable tiles; small jitters yield none. Drop it toward 20 and a flick produces a solid smear of overlapping tiles; raise it past ~250 and normal mouse movement never triggers the effect at all. |
| poolSize | = 22 | The ring must not wrap while a tile is still visible. At 520ms of life you would have to move 22 x 130 = 2860px in 520ms to catch your own tail — a flick nobody performs by accident. |
| plateCount | = 17 | Deliberately coprime with the 22 slots, so the plate-to-slot pairing never settles into a short repeating pattern as the ring advances. Also fewer plates than slots, so the memory cost is bounded independently of pool size. |
| IN_DURATION / HOLD / OUT_DURATION | = 0.22 / 0.1 / 0.2 | 520ms total. Fast in with power2.out (arrives), a beat of stillness so the tile is actually readable, then a power2.in exit (leaves). Collapse the hold and the trail reads as a blur instead of as a sequence of pictures. |
| START_SCALE | = 0.92 | An 8% growth on entry. Enough to read as arriving, small enough not to pop — at 0.7 it looks like a modal opening. |
| TILT | = 7 | Degrees of seeded rotation either way. Perfectly square tiles read as UI panels; a few degrees reads as prints dropped on a table. Past ~12 the corners of successive tiles collide and the trail looks untidy. |
| PLATE_SCALE | = 2 | Backing-store multiplier for the plate canvases. Fixed rather than devicePixelRatio so the drawn output is byte-identical on every machine and a visual regression is actually visible. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
No listener attaches and nothing animates. Three plates are drawn once into the first three pool slots, fanned across the centre with a few degrees of rotation each, so the panel is a composed still of what the effect produces rather than an empty rectangle.
Removes the single pointermove listener, calls gsap.killTweensOf on every pool node — which also kills the per-spawn timelines, since they only target those nodes — then gsap.set(nodes, { clearProps: "all" }). The offscreen plate canvases are plain locals and are collected with the closure; nothing was allocated that needs revoking.
Sweep the pointer slowly across the panel and tiles drop behind it roughly every 130px, each readable for about half a second before fading. Jiggle the pointer in place and nothing spawns. Flick as fast as you can and you get a spaced trail, not a smear, and no tile ever restarts mid-fade with a flicker. Enter the panel from an edge and no tile appears until you have actually moved. On touch or with reduced motion you see three static fanned plates.
Pitfalls
- Creating an element per spawn. A fast sweep across a wide hero allocates a hundred nodes, each forcing a style recalc, a layer promotion and a paint at the exact moment the main thread is busiest — the trail stutters precisely when it is being used hardest.
- Spawning on every
pointermoveinstead of on distance travelled. Events fire on sub-pixel movement, so a flick produces a solid smear and a slow drift produces a dense clump; "moved" and "moved enough" are different predicates. - Measuring the first move against a zeroed anchor. The distance from (0, 0) to wherever the pointer entered is usually well over the threshold, so a tile fires the instant the pointer crosses the boundary, before any movement has happened.
- Reusing a slot without
gsap.killTweensOf(node)first. If the ring wraps onto a tile that is still fading out, two tweens fight overopacityand it flickers rather than restarting cleanly. - Recomputing z-order from tile ages. A monotonic counter is correct, free, and cannot get out of sync; sorting is a whole class of bug bought for nothing.
- Painting the gradient plate at spawn time. The gradient fill, the radial sheen and the text layout all land on the same frame as the tween start, which is the one frame in the effect that must not drop. Paint once at mount and blit.
- Omitting
will-change: transformfrom the pool. The browser promotes the tile to its own layer partway through its 220ms entry, so the first frames of every spawn are visibly cheaper-looking than the rest.
Notes
- Gated on
(pointer: fine). On touch apointermoveonly exists during a drag, so the trail would appear only under a finger that is already covering it and would then be stranded mid-life on lift. The touch path renders the same static fan as reduced motion. - The tiles sit behind the content layer (
z-0under az-10child wrapper) so headline copy stays readable while the trail passes under it. - Plate choice runs on
createRng(seed), neverMath.random(), so the sequence is identical on every load and across a server render.