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. 1468 words of exact values — rewritten live as you tune the Playground.
### 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 the accent colour #d4ff3a). The native cursor is replaced by a small #d4ff3a 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, up to ±14°), 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 (attract mode: true). 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, #d4ff3a); 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, #d4ff3a) }`.
- `.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 (× 0.8 on coarse pointers), `HOLD = 0.6` s, `BLOOM = 0.5` s, `MAX_TILT = 14` deg, `ATTRACT = true`.
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) < 40) 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 (only when `ATTRACT` is `true` → true): `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 0.8 × 40px, 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 px, × 0.8 on touch) — spacing between drops.
- Life: bloom 0.5s `expo.out`, hold until 0.6s, dissolve 0.5s `power2.in`; raise `HOLD` for longer-lived trails.
- `tilt = dx × 0.12` clamped ±14 — set `MAX_TILT` to 0 for perfectly upright tiles.
- `ATTRACT` (true) — the idle figure-eight demo.
- Accent colour (`#d4ff3a`) — ring cursor, full stop and counter.
- 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 (when attract mode is true); 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 over 0.5s 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 0.8× 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 (accent `#d4ff3a` for the ring cursor and counter; attract-mode demo path: true); use my own images for the tiles. ### EFFECT (React / Next.js): Image Trail Following Cursor
Build **Image Trail Following Cursor** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ImageTrailFollowingCursor.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export default function ImageTrailFollowingCursor() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="trail">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ImageTrailFollowingCursor.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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 the accent colour #d4ff3a). The native cursor is replaced by a small #d4ff3a 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, up to ±14°), 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 (attract mode: true). 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, #d4ff3a); 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, #d4ff3a) }`.
- `.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 (× 0.8 on coarse pointers), `HOLD = 0.6` s, `BLOOM = 0.5` s, `MAX_TILT = 14` deg, `ATTRACT = true`.
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) < 40) 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 (only when `ATTRACT` is `true` → true): `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 0.8 × 40px, 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 px, × 0.8 on touch) — spacing between drops.
- Life: bloom 0.5s `expo.out`, hold until 0.6s, dissolve 0.5s `power2.in`; raise `HOLD` for longer-lived trails.
- `tilt = dx × 0.12` clamped ±14 — set `MAX_TILT` to 0 for perfectly upright tiles.
- `ATTRACT` (true) — the idle figure-eight demo.
- Accent colour (`#d4ff3a`) — ring cursor, full stop and counter.
- 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 (when attract mode is true); 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 over 0.5s 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<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>
```
```css
.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(--knob-accent, 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(--knob-accent, 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(--knob-accent, 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; }
}
```
```js
// Image Trail Following Cursor — a recycled pool of procedural tiles dropped every N px 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'];
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { threshold: 40, hold: 0.6, bloomDuration: 0.5, maxTilt: 14, attract: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
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;
}
// Tiles are painted once (after the fonts resolve) and survive Playground rebuilds.
const tiles = [];
const tilesReady = Promise.all([document.fonts.load('italic 300 20px Fraunces'), document.fonts.load('500 10px "JetBrains Mono"')])
.catch(() => {})
.then(() => { for (let i = 0; i < POOL; i++) { const t = paintTile(i); layer.appendChild(t); tiles.push(t); } });
let dropped = 0, zTop = 0, next = 0, lastX = null, lastY = null;
let ctx, generation = 0;
function build() {
ctx?.revert();
const gen = ++generation;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) stage.style.setProperty('--knob-accent', accent); else stage.style.removeProperty('--knob-accent');
const THRESHOLD = K('threshold');
const HOLD = K('hold');
const BLOOM = K('bloomDuration');
const MAX_TILT = K('maxTilt');
const ATTRACT = K('attract');
lastX = lastY = null;
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 });
tilesReady.then(() => {
if (gen !== generation) return; // a newer build took over while the fonts loaded
ctx.add(() => {
gsap.killTweensOf(tiles);
gsap.set(tiles, { xPercent: -50, yPercent: -50, opacity: 0, scale: 1, rotation: 0 });
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) * (MAX_TILT / 2), 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 ? THRESHOLD * 0.8 : THRESHOLD; // 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(-MAX_TILT, MAX_TILT, 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: BLOOM, ease: 'expo.out' }, 0)
.to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, HOLD);
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 = ATTRACT ? 0 : 2;
const auto = { t: 0 };
const autoTween = ATTRACT ? 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);
},
}) : null;
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();
gsap.killTweensOf(tiles);
stage.removeEventListener('pointermove', onMove);
stage.removeEventListener('pointerdown', onMove);
stage.removeEventListener('pointerleave', onLeave);
};
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
tiles.forEach((t) => t.remove());
});
```
### EFFECT (single HTML file): Image Trail Following Cursor
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Image Trail Following Cursor** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Image Trail Following Cursor</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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 the accent colour #d4ff3a). The native cursor is replaced by a small #d4ff3a 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, up to ±14°), 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 (attract mode: true). 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, #d4ff3a); 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, #d4ff3a) }`.
- `.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 (× 0.8 on coarse pointers), `HOLD = 0.6` s, `BLOOM = 0.5` s, `MAX_TILT = 14` deg, `ATTRACT = true`.
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) < 40) 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 (only when `ATTRACT` is `true` → true): `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 0.8 × 40px, 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 px, × 0.8 on touch) — spacing between drops.
- Life: bloom 0.5s `expo.out`, hold until 0.6s, dissolve 0.5s `power2.in`; raise `HOLD` for longer-lived trails.
- `tilt = dx × 0.12` clamped ±14 — set `MAX_TILT` to 0 for perfectly upright tiles.
- `ATTRACT` (true) — the idle figure-eight demo.
- Accent colour (`#d4ff3a`) — ring cursor, full stop and counter.
- 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 (when attract mode is true); 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 over 0.5s 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.
---
**Reference implementation (use these exact values and class names):**
```html
<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>
```
```css
.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(--knob-accent, 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(--knob-accent, 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(--knob-accent, 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; }
}
```
```js
// Image Trail Following Cursor — a recycled pool of procedural tiles dropped every N px 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'];
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { threshold: 40, hold: 0.6, bloomDuration: 0.5, maxTilt: 14, attract: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
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;
}
// Tiles are painted once (after the fonts resolve) and survive Playground rebuilds.
const tiles = [];
const tilesReady = Promise.all([document.fonts.load('italic 300 20px Fraunces'), document.fonts.load('500 10px "JetBrains Mono"')])
.catch(() => {})
.then(() => { for (let i = 0; i < POOL; i++) { const t = paintTile(i); layer.appendChild(t); tiles.push(t); } });
let dropped = 0, zTop = 0, next = 0, lastX = null, lastY = null;
let ctx, generation = 0;
function build() {
ctx?.revert();
const gen = ++generation;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) stage.style.setProperty('--knob-accent', accent); else stage.style.removeProperty('--knob-accent');
const THRESHOLD = K('threshold');
const HOLD = K('hold');
const BLOOM = K('bloomDuration');
const MAX_TILT = K('maxTilt');
const ATTRACT = K('attract');
lastX = lastY = null;
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 });
tilesReady.then(() => {
if (gen !== generation) return; // a newer build took over while the fonts loaded
ctx.add(() => {
gsap.killTweensOf(tiles);
gsap.set(tiles, { xPercent: -50, yPercent: -50, opacity: 0, scale: 1, rotation: 0 });
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) * (MAX_TILT / 2), 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 ? THRESHOLD * 0.8 : THRESHOLD; // 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(-MAX_TILT, MAX_TILT, 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: BLOOM, ease: 'expo.out' }, 0)
.to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, HOLD);
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 = ATTRACT ? 0 : 2;
const auto = { t: 0 };
const autoTween = ATTRACT ? 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);
},
}) : null;
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();
gsap.killTweensOf(tiles);
stage.removeEventListener('pointermove', onMove);
stage.removeEventListener('pointerdown', onMove);
stage.removeEventListener('pointerleave', onLeave);
};
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
tiles.forEach((t) => t.remove());
});
```
### EFFECT (Framer & Webflow notes): Image Trail Following Cursor
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withImageTrailFollowingCursor(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `trail`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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 the accent colour #d4ff3a). The native cursor is replaced by a small #d4ff3a 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, up to ±14°), 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 (attract mode: true). 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, #d4ff3a); 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, #d4ff3a) }`.
- `.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 (× 0.8 on coarse pointers), `HOLD = 0.6` s, `BLOOM = 0.5` s, `MAX_TILT = 14` deg, `ATTRACT = true`.
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) < 40) 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 (only when `ATTRACT` is `true` → true): `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 0.8 × 40px, 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 px, × 0.8 on touch) — spacing between drops.
- Life: bloom 0.5s `expo.out`, hold until 0.6s, dissolve 0.5s `power2.in`; raise `HOLD` for longer-lived trails.
- `tilt = dx × 0.12` clamped ±14 — set `MAX_TILT` to 0 for perfectly upright tiles.
- `ATTRACT` (true) — the idle figure-eight demo.
- Accent colour (`#d4ff3a`) — ring cursor, full stop and counter.
- 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 (when attract mode is true); 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 over 0.5s 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>
.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(--knob-accent, 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(--knob-accent, 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(--knob-accent, 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 N px 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'];
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { threshold: 40, hold: 0.6, bloomDuration: 0.5, maxTilt: 14, attract: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
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;
}
// Tiles are painted once (after the fonts resolve) and survive Playground rebuilds.
const tiles = [];
const tilesReady = Promise.all([document.fonts.load('italic 300 20px Fraunces'), document.fonts.load('500 10px "JetBrains Mono"')])
.catch(() => {})
.then(() => { for (let i = 0; i < POOL; i++) { const t = paintTile(i); layer.appendChild(t); tiles.push(t); } });
let dropped = 0, zTop = 0, next = 0, lastX = null, lastY = null;
let ctx, generation = 0;
function build() {
ctx?.revert();
const gen = ++generation;
const accent = String(K('accent')).toLowerCase();
if (accent !== DEFAULTS.accent) stage.style.setProperty('--knob-accent', accent); else stage.style.removeProperty('--knob-accent');
const THRESHOLD = K('threshold');
const HOLD = K('hold');
const BLOOM = K('bloomDuration');
const MAX_TILT = K('maxTilt');
const ATTRACT = K('attract');
lastX = lastY = null;
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 });
tilesReady.then(() => {
if (gen !== generation) return; // a newer build took over while the fonts loaded
ctx.add(() => {
gsap.killTweensOf(tiles);
gsap.set(tiles, { xPercent: -50, yPercent: -50, opacity: 0, scale: 1, rotation: 0 });
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) * (MAX_TILT / 2), 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 ? THRESHOLD * 0.8 : THRESHOLD; // 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(-MAX_TILT, MAX_TILT, 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: BLOOM, ease: 'expo.out' }, 0)
.to(tile, { scale: 0.82, opacity: 0, duration: 0.5, ease: 'power2.in' }, HOLD);
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 = ATTRACT ? 0 : 2;
const auto = { t: 0 };
const autoTween = ATTRACT ? 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);
},
}) : null;
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();
gsap.killTweensOf(tiles);
stage.removeEventListener('pointermove', onMove);
stage.removeEventListener('pointerdown', onMove);
stage.removeEventListener('pointerleave', onLeave);
};
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => {
ctx?.revert();
tiles.forEach((t) => t.remove());
});