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.

Loading

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.

prompt.md · Image Trail Following Cursor
### 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.

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>