Image Trail Following Cursor
Moving the pointer across a dark stage leaves a trail of grainy gradient tiles: every 40px of travel a new tile blooms under the cursor with a slight twist, hangs for 0.6s and dissolves, stacking above the last.
- Cursor
- gsap
- canvas2d
- intermediate
- medium perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1405 words of exact values — targets, eases, durations, triggers.
### EFFECT: Image Trail Following Cursor
**What it does (visual):** A full-viewport dark stage with thin mono chrome in the corners and one large, light serif headline dead-centre: "Move your cursor." (the full stop is lime). The native cursor is replaced by a small lime ring that trails the pointer by a fraction of a second. Every time the pointer has travelled 40px since the last drop, a portrait image tile (240×300, 4:5, rounded 4px, soft shadow) appears centred on the pointer: it blooms from 60% to 100% size in 0.5s with a quick deceleration while straightening from a few degrees of tilt (the tilt follows the direction of travel), sits on top of every previous tile, hangs for a beat and then, 0.6s after appearing, shrinks to 82% and fades out over 0.5s. A fast sweep leaves a dense, overlapping ribbon of tiles that dissolves behind the pointer; a slow drift drops them one by one. The tiles are twelve distinct grainy gradient "photographs", each with a serif title and a mono index/year, cycling in order. The headline uses `mix-blend-mode: difference` so tiles passing beneath it invert the letters. Until the pointer moves, a scripted figure-eight path demonstrates the trail on its own. A counter top-right tallies the drops.
**Tech stack:**
- GSAP 3.13+ core (`gsap.timeline`, `gsap.quickTo`, `gsap.matchMedia`) + Canvas 2D for the tiles.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `import gsap from "gsap";`
- Fonts: a serif (demo uses Fraunces 300 italic for tile captions and the headline), a mono for chrome. In production, replace the canvas tiles with twelve `<img>` elements of the same size.
**HTML structure:**
```html
<section class="trail">
<div class="trail__layer" aria-hidden="true"><!-- 12 .trail__tile canvases injected here --></div>
<div class="trail__cursor" aria-hidden="true"></div>
<header class="trail__chrome trail__chrome--top">
<p class="trail__eyebrow">Cursor · Image trail</p>
<p class="trail__count" aria-hidden="true"><span class="trail__count-value">00</span> dropped</p>
</header>
<h1 class="trail__title"><span class="trail__title-text">Move your cursor</span><span class="trail__dot">.</span></h1>
<footer class="trail__chrome trail__chrome--bottom">
<p class="trail__hint">Twelve tiles, painted once, recycled forever</p>
<p class="trail__sign">Archive · 2019 — 2026</p>
</footer>
</section>
```
**CSS requirements:**
- `.trail`: `position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; touch-action: none; user-select: none;` plus `cursor: none` under `@media (pointer: fine)`.
- `.trail__layer`: `position: absolute; inset: 0; pointer-events: none; z-index: 1;`
- `.trail__tile`: `position: absolute; left: 0; top: 0; width: clamp(140px, 18vw, 240px); height: auto; aspect-ratio: 4 / 5; border-radius: 4px; opacity: 0; will-change: transform, opacity; box-shadow: 0 30px 60px -20px rgba(0,0,0,.6);`
- `.trail__cursor`: `position: absolute; width: 14px; height: 14px; border: 1.5px solid var(--accent); border-radius: 50%; pointer-events: none; z-index: 4; opacity: 0; transform: translate(-50%, -50%);` hidden under `(hover: none), (pointer: coarse)`.
- `.trail__title`: `position: relative; z-index: 2; align-self: center; justify-self: center; text-align: center; font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .95; pointer-events: none;` with `.trail__title-text { mix-blend-mode: difference; color: #fff }` (so tiles passing underneath invert the letters and the heading reads dark on a light theme) and `.trail__dot { color: var(--accent) }`.
- `.trail__chrome`: flex space-between, mono 11px uppercase `letter-spacing: .14em`, muted, `z-index: 2; pointer-events: none`.
- Under 768px: padding `1.5rem 5vw`, hide `.trail__sign`.
**Animation spec (exact values):**
1. Constants: `POOL = 12`, `TILE_W = 240`, `TILE_H = 300`, `threshold = 40` px (32 on coarse pointers).
2. Tile painting (once, after `document.fonts.load()` for the serif italic and the mono): canvas `width = 240 × dpr`, `height = 300 × dpr`, `dpr = min(devicePixelRatio, 2)` (halved in preview). Per tile `i`, `hue = (i × 137.508 + 18) % 360`:
- linear gradient (0,0)→(w,h): `hsl(hue 40% 24%)` → `hsl(hue+40 50% 7%)`;
- radial glow centred (0.3w, 0.26h), radius 0.95w: `hsl(hue+55 85% 72% / .95)` → `hsl(hue+25 70% 48% / .4)` at 45% → transparent;
- radial shade centred (0.85w, 0.95h), radius 0.9w: `hsl(hue+190 60% 20% / .55)` → transparent;
- grain: a 256×256 noise canvas (grey 110–200) drawn as a repeating pattern with `globalCompositeOperation = 'overlay'`, `globalAlpha = 0.5`;
- 1px `rgba(255,255,255,.16)` inner frame; title in `italic 300 20px Fraunces` at (16, 34) `rgba(255,255,255,.9)`; `N°01 — 2019` in `500 10px JetBrains Mono` at (16, h − 18) `rgba(255,255,255,.65)`.
Append all twelve to `.trail__layer`, `gsap.set(tiles, { xPercent: -50, yPercent: -50 })`.
3. Entrance: `gsap.from('.trail__title', { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', delay: 0.1 })`; `gsap.from('.trail__chrome', { opacity: 0, duration: 0.8, ease: 'power2.out', delay: 0.4 })`.
4. `drop(x, y, dx, dy)`: `tile = tiles[next]; next = (next + 1) % 12; gsap.killTweensOf(tile); tilt = clamp(-14, 14, dx × 0.12);`
```js
gsap.timeline()
.set(tile, { x, y, zIndex: ++zTop, rotation: tilt - 6, scale: 0.6, opacity: 0 })
.to(tile, { scale: 1, rotation: tilt, opacity: 1, duration: 0.5, ease: 'expo.out' }, 0)
.to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, 0.6);
```
Target: the recycled tile; `scale 0.6 → 1` + `rotation (tilt − 6) → tilt` + `opacity 0 → 1` over 0.5s `expo.out`; then at position 0.6s `scale 1 → 0.82`, `opacity 1 → 0` over 0.5s `power2.in`. Increment the counter text (`String(++dropped % 100).padStart(2, '0')`).
5. `handle(x, y)`: first call just records the point and drops once; afterwards `if (hypot(x − lastX, y − lastY) < threshold) return;` else update `lastX/Y` and `drop(x, y, dx, dy)`.
6. Pointer: `pointermove` and `pointerdown` on `.trail` → stage-relative coordinates; feed `gsap.quickTo(cursor, 'x'/'y', { duration: 0.25, ease: 'power3.out' })` and a third `gsap.quickTo(cursor, 'opacity', { duration: 0.3 })` set to 1 (never use `overwrite: true` on the ring — it would kill the position quickTo tweens); `pointerleave` sets the opacity quickTo to 0.
7. Attract mode: `auto = { t: 0 }`, `gsap.to(auto, { t: 1, duration: 5, ease: 'none', repeat: -1, onUpdate })` where `a = t × 2π`, `x = w/2 + sin(a) × 0.32w`, `y = h/2 + sin(2a) × 0.22h` → `handle(x, y)`. Kill it on the **second** real pointer event (a single synthetic jump keeps the demo alive) and reset `lastX` so the user's first drop lands under their pointer.
**Interaction:** Purely pointer-driven. Speed controls density (distance threshold, not time); direction controls the tilt sign; z-order always puts the newest tile on top. The ring cursor is cosmetic and only exists for fine pointers. Pressing/touching also drops a tile (`pointerdown`). The headline inverts wherever a tile passes under it.
**Responsive behavior:** Tiles shrink via `clamp(140px, 18vw, 240px)` while keeping 4:5. On touch (`(hover: none), (pointer: coarse)` through `gsap.matchMedia`) the stage has `touch-action: none` so dragging a finger produces continuous `pointermove` events that leave the same trail; the threshold drops to 32px, the ring cursor is hidden and the native cursor is not suppressed. Under 768px the signature line is hidden.
**Accessibility:** With `prefers-reduced-motion: reduce` no pointer handling runs: five tiles are placed once in a shallow arc behind the headline (rotations −14° to +14°, opacity 1) as a static composition, the counter reads "05", `cursor` and `touch-action` return to defaults. The tile layer, ring and counter are `aria-hidden`; the only semantic content is the `<h1>` and the mono captions. Nothing requires hover to be understood.
**Performance rules:** Tiles are painted once (12 canvases, grain via one shared 256² pattern) — no per-frame drawing. Only `transform` and `opacity` are tweened; `will-change` is on `.trail__tile` only. The pool caps DOM nodes at 12 regardless of pointer speed and `killTweensOf` prevents stacked timelines on a recycled tile. The attract path is a GSAP tween, so `ml:pause` / tab hiding freeze it with the global timeline. Everything is inside `gsap.context()`; `ctx.revert()` plus removing the canvases cleans up on unmount.
**Customization knobs:**
- `POOL` (12) — more tiles = denser trail at high speed; 8 feels sparse.
- `threshold` (40 / 32 px) — spacing between drops.
- Life: bloom 0.5s `expo.out`, hold until 0.6s, dissolve 0.5s `power2.in`; change the `0.6` position for longer-lived trails.
- `tilt = dx × 0.12` clamped ±14 — set to 0 for perfectly upright tiles.
- Tile size clamp and `NAMES[]` / hue formula — swap for real `<img>` elements with the same class.
**Acceptance criteria:**
- [ ] Before any pointer movement the figure-eight demo drops tiles; the first real pointer move takes over seamlessly.
- [ ] A new tile appears every ~40px of travel, centred on the pointer, newest on top.
- [ ] Each tile scales 0.6 → 1 with a settling tilt, then shrinks and fades starting 0.6s after it appeared.
- [ ] Never more than 12 tiles in the DOM; no stacked tweens on a recycled tile.
- [ ] Touch drag leaves a trail; reduced motion shows the static arc; no console errors. Add this effect to my existing site/section: **Image Trail Following Cursor**. Inside my hero section add an absolutely positioned `.trail__layer` containing a pool of 12 `.trail__tile` images (240×300, `position: absolute; opacity: 0`) and give the section `overflow: hidden; touch-action: none`. With GSAP, `gsap.set(tiles, { xPercent: -50, yPercent: -50 })`, then on `pointermove`/`pointerdown` compute the section-relative point and, whenever the pointer has travelled ≥ 40px since the last drop, recycle the next tile: `gsap.killTweensOf(tile)` and run `gsap.timeline().set(tile, { x, y, zIndex: ++z, rotation: tilt - 6, scale: 0.6, opacity: 0 }).to(tile, { scale: 1, rotation: tilt, opacity: 1, duration: 0.5, ease: 'expo.out' }, 0).to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, 0.6)` with `tilt = clamp(-14, 14, dx × 0.12)`. Use `gsap.matchMedia` to lower the threshold to 32px on coarse pointers and hide any custom cursor there. For `prefers-reduced-motion`, skip the listeners and place five tiles statically behind the headline. Wrap in `gsap.context()` and revert on unmount. Keep my headline, fonts and colours; use my own images for the tiles. Source
Full source code
<section class="trail">
<div class="trail__layer" aria-hidden="true"></div>
<div class="trail__cursor" aria-hidden="true"></div>
<header class="trail__chrome trail__chrome--top">
<p class="trail__eyebrow">Cursor · Image trail</p>
<p class="trail__count" aria-hidden="true"><span class="trail__count-value">00</span> dropped</p>
</header>
<h1 class="trail__title"><span class="trail__title-text">Move your cursor</span><span class="trail__dot">.</span></h1>
<footer class="trail__chrome trail__chrome--bottom">
<p class="trail__hint">Twelve tiles, painted once, recycled forever</p>
<p class="trail__sign">Archive · 2019 — 2026</p>
</footer>
</section>
.trail { position: relative; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; padding: 2.5rem 6vw; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; user-select: none; }
.trail__layer { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.trail__tile { position: absolute; left: 0; top: 0; width: clamp(140px, 18vw, 240px); height: auto; aspect-ratio: 4 / 5; border-radius: 4px; opacity: 0; will-change: transform, opacity; box-shadow: 0 30px 60px -20px rgba(0,0,0,.6); }
.trail__cursor { position: absolute; left: 0; top: 0; width: 14px; height: 14px; border: 1.5px solid var(--accent); border-radius: 50%; pointer-events: none; z-index: 4; opacity: 0; transform: translate(-50%, -50%); }
.trail__chrome { position: relative; z-index: 2; display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); pointer-events: none; }
.trail__count { font-variant-numeric: tabular-nums; }
.trail__count-value { color: var(--accent); }
.trail__title { position: relative; z-index: 2; align-self: center; justify-self: center; text-align: center; font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .95; pointer-events: none; }
.trail__title-text { mix-blend-mode: difference; color: #fff; } /* tiles passing underneath invert the letters; difference on white reads dark on the light theme */
.trail__dot { color: var(--accent); }
.trail__sign { text-align: right; }
@media (pointer: fine) { .trail { cursor: none; } }
@media (hover: none), (pointer: coarse) { .trail__cursor { display: none; } }
@media (max-width: 767px) {
.trail { padding: 1.5rem 5vw; }
.trail__sign { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.trail { cursor: auto; touch-action: auto; }
.trail__cursor { display: none; }
.trail__tile { will-change: auto; }
}
// Image Trail Following Cursor — a recycled pool of procedural tiles dropped every 40px of pointer travel.
const POOL = 12; // max tiles ever on screen
const TILE_W = 240, TILE_H = 300;
const NAMES = ['Aurora', 'Ember', 'Moss', 'Violet Hour', 'Saffron', 'Tide', 'Basalt', 'Cinder', 'Lagoon', 'Umber', 'Halcyon', 'Nocturne'];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const stage = document.querySelector('.trail');
const layer = document.querySelector('.trail__layer');
const cursor = document.querySelector('.trail__cursor');
const countValue = document.querySelector('.trail__count-value');
// ---- Paint the twelve tiles once ---------------------------------------------------------------
const dpr = Math.min(devicePixelRatio || 1, 2) / (window.MotionLab?.preview ? 2 : 1);
const grain = (() => {
const c = document.createElement('canvas'); c.width = c.height = 256;
const g = c.getContext('2d'); const img = g.createImageData(256, 256); const d = img.data;
for (let p = 0; p < d.length; p += 4) { const v = 110 + Math.random() * 90; d[p] = d[p + 1] = d[p + 2] = v; d[p + 3] = 255; }
g.putImageData(img, 0, 0); return c;
})();
function paintTile(i) {
const c = document.createElement('canvas');
c.width = Math.round(TILE_W * dpr); c.height = Math.round(TILE_H * dpr);
c.className = 'trail__tile';
const g = c.getContext('2d'); g.scale(dpr, dpr);
const w = TILE_W, h = TILE_H, hue = (i * 137.508 + 18) % 360;
const base = g.createLinearGradient(0, 0, w, h);
base.addColorStop(0, `hsl(${hue} 40% 24%)`); base.addColorStop(1, `hsl(${(hue + 40) % 360} 50% 7%)`);
g.fillStyle = base; g.fillRect(0, 0, w, h);
const glow = g.createRadialGradient(w * 0.3, h * 0.26, 0, w * 0.3, h * 0.26, w * 0.95);
glow.addColorStop(0, `hsl(${(hue + 55) % 360} 85% 72% / .95)`); glow.addColorStop(0.45, `hsl(${(hue + 25) % 360} 70% 48% / .4)`); glow.addColorStop(1, 'hsl(0 0% 0% / 0)');
g.fillStyle = glow; g.fillRect(0, 0, w, h);
const shade = g.createRadialGradient(w * 0.85, h * 0.95, 0, w * 0.85, h * 0.95, w * 0.9);
shade.addColorStop(0, `hsl(${(hue + 190) % 360} 60% 20% / .55)`); shade.addColorStop(1, 'hsl(0 0% 0% / 0)');
g.fillStyle = shade; g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'overlay'; g.globalAlpha = 0.5;
g.fillStyle = g.createPattern(grain, 'repeat'); g.fillRect(0, 0, w, h);
g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1;
g.strokeStyle = 'rgba(255,255,255,.16)'; g.lineWidth = 1; g.strokeRect(0.5, 0.5, w - 1, h - 1);
g.fillStyle = 'rgba(255,255,255,.9)'; g.font = 'italic 300 20px Fraunces, Georgia, serif'; g.fillText(NAMES[i], 16, 34);
g.fillStyle = 'rgba(255,255,255,.65)'; g.font = '500 10px "JetBrains Mono", ui-monospace, monospace';
g.fillText(`N°${String(i + 1).padStart(2, '0')} — ${2019 + (i % 8)}`, 16, h - 18);
return c;
}
const tiles = [];
let dropped = 0, zTop = 0, next = 0, lastX = null, lastY = null;
const ctx = gsap.context(() => {
gsap.from('.trail__title', { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', delay: 0.1 });
gsap.from('.trail__chrome', { opacity: 0, duration: 0.8, ease: 'power2.out', delay: 0.4 });
const ready = Promise.all([document.fonts.load('italic 300 20px Fraunces'), document.fonts.load('500 10px "JetBrains Mono"')]).catch(() => {});
ready.then(() => {
for (let i = 0; i < POOL; i++) { const t = paintTile(i); layer.appendChild(t); tiles.push(t); }
gsap.set(tiles, { xPercent: -50, yPercent: -50 });
if (reduced) {
// Final state: five tiles fanned behind the headline, no motion.
const r = stage.getBoundingClientRect();
tiles.slice(0, 5).forEach((t, i) => {
const a = (i - 2) * 0.36;
gsap.set(t, { x: r.width / 2 + Math.sin(a) * r.width * 0.28, y: r.height / 2 + Math.cos(a) * -r.height * 0.12 + r.height * 0.1, rotation: (i - 2) * 7, scale: 1, opacity: 1, zIndex: i + 1 });
});
countValue.textContent = '05';
return;
}
const mm = gsap.matchMedia();
mm.add({ fine: '(hover: hover) and (pointer: fine)', coarse: '(hover: none), (pointer: coarse)' }, (mmCtx) => {
const threshold = mmCtx.conditions.coarse ? 32 : 40; // px of travel between drops
const cursorX = gsap.quickTo(cursor, 'x', { duration: 0.25, ease: 'power3.out' });
const cursorY = gsap.quickTo(cursor, 'y', { duration: 0.25, ease: 'power3.out' });
const cursorAlpha = gsap.quickTo(cursor, 'opacity', { duration: 0.3, ease: 'power2.out' });
const drop = (x, y, dx, dy) => {
const tile = tiles[next]; next = (next + 1) % POOL;
gsap.killTweensOf(tile);
const tilt = gsap.utils.clamp(-14, 14, dx * 0.12);
gsap.timeline()
.set(tile, { x, y, zIndex: ++zTop, rotation: tilt - 6, scale: 0.6, opacity: 0 })
.to(tile, { scale: 1, rotation: tilt, opacity: 1, duration: 0.5, ease: 'expo.out' }, 0)
.to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, 0.6);
countValue.textContent = String(++dropped % 100).padStart(2, '0');
};
const handle = (x, y) => {
if (lastX === null) { lastX = x; lastY = y; drop(x, y, 0, 0); return; }
const dx = x - lastX, dy = y - lastY;
if (Math.hypot(dx, dy) < threshold) return;
lastX = x; lastY = y; drop(x, y, dx, dy);
};
// Attract mode: a figure-eight runs until the pointer has moved twice (ignores a single synthetic jump).
let moves = 0;
const auto = { t: 0 };
const autoTween = gsap.to(auto, {
t: 1, duration: 5, ease: 'none', repeat: -1,
onUpdate() {
const r = stage.getBoundingClientRect();
const a = auto.t * Math.PI * 2;
handle(r.width / 2 + Math.sin(a) * r.width * 0.32, r.height / 2 + Math.sin(a * 2) * r.height * 0.22);
},
});
const onMove = (e) => {
const r = stage.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
if (mmCtx.conditions.fine) { cursorX(x); cursorY(y); cursorAlpha(1); }
if (++moves === 2 && autoTween.isActive()) { autoTween.kill(); lastX = null; }
if (moves >= 2) handle(x, y);
};
const onLeave = () => cursorAlpha(0);
stage.addEventListener('pointermove', onMove);
stage.addEventListener('pointerdown', onMove);
stage.addEventListener('pointerleave', onLeave);
return () => {
autoTween.kill();
stage.removeEventListener('pointermove', onMove);
stage.removeEventListener('pointerdown', onMove);
stage.removeEventListener('pointerleave', onLeave);
};
});
});
});
window.MotionLab?.onCleanup(() => {
ctx.revert();
tiles.forEach((t) => t.remove());
});