# Cursor Image Trail (vanilla JS + CSS transitions)

## Goal

Build a three-section page whose middle, light-colored full-viewport panel spawns a trail of photos under the cursor: every time the mouse travels far enough (or sits idle, or the page scrolls) a new 200×200 image pops in at the cursor position with a random tilt, scaling up from 0, then collapses back to scale 0 and unmounts after a short lifespan. The result is a continuous, self-cleaning stream of tilted photos chasing the pointer.

## Tech

Vanilla HTML/CSS/JS with ES module imports. **No GSAP is used** — the animation engine is a `requestAnimationFrame` loop plus CSS `transform` transitions with custom `cubic-bezier` easings. Install and import `lenis` (npm) for smooth scrolling. Everything runs inside a `DOMContentLoaded` handler.

## Layout / HTML

Three stacked `<section>` elements, each exactly one viewport tall:

```
<section class="intro">
  <h1>Dynamic Cursor Trail Animation</h1>
</section>

<section class="trail-container">
  <p>( Move your cursor around and see the magic unfold )</p>
</section>

<section class="outro">
  <h1>Wrapping Up</h1>
</section>
```

The `.trail-container` section is the interactive zone: trail `<img class="trail-img">` elements are created and appended directly into it at runtime. Load the script with `<script type="module" src="./script.js">`.

## Styling

- Global reset: `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `body`: `font-family: "PP Neue Montreal", sans-serif` (any clean grotesque sans fallback is fine); `background-color: #1e1e1e`.
- `h1`: color `#fff`, `font-size: 5vw`, `font-weight: 500`, `user-select: none`.
- `p`: `text-transform: uppercase`, `text-align: center`, `font-family: "Akkurat Mono", monospace` (monospace fallback fine), `font-weight: 600`, `user-select: none`. Default black text.
- Every `section`: `position: relative`, `width: 100vw`, `height: 100vh`, `display: flex`, `justify-content: center`, `align-items: center`, `overflow: hidden` (crucial — trail images spawning near the edges must be clipped by the section).
- `.trail-container`: `background-color: #fcfcfc` (near-white panel sandwiched between the two dark sections).
- `.trail-img`: `position: absolute`, `width: 200px`, `height: 200px`, `object-fit: cover`, `border-radius: 4px`, `transform-origin: center`, `pointer-events: none`, `will-change: transform`.

## Animation engine (be precise — no GSAP, no ScrollTrigger)

### Config constants

```
imageCount: 35            // pool of image URLs
imageLifespan: 750        // ms an image lives before its exit starts
removalDelay: 50          // ms minimum between two consecutive removals
mouseThreshold: 100       // px of cursor travel needed to spawn a new image
scrollThreshold: 50       // ms throttle between scroll-spawned images
idleCursorInterval: 300   // ms between spawns while the cursor rests in the panel
inDuration: 750           // ms entrance transition
outDuration: 1000         // ms exit transition
inEasing:  cubic-bezier(.07, .5, .5, 1)    // fast start, soft settle
outEasing: cubic-bezier(.87, 0, .13, 1)    // aggressive ease-in-out
```

Build an array of 35 image URLs (`img1 … img35`). Keep a `trail` queue of live images, each entry `{ element, rotation, removeTime }`.

### State

Track `mouseX/mouseY`, `lastMouseX/lastMouseY` (position of the last spawn), booleans `isMoving`, `isCursorInContainer`, `isScrolling`, `scrollTicking`, and timestamps `lastRemovalTime`, `lastSteadyImageTime`, `lastScrollTime`.

`isInContainer(x, y)` compares viewport coordinates against `container.getBoundingClientRect()` (left/right/top/bottom inclusive).

Seed the initial cursor position with a one-shot `mouseover` listener on `document`: on first fire, set `mouseX/Y` and `lastMouseX/Y` from `event.clientX/clientY`, compute `isCursorInContainer`, then remove the listener.

### Spawning an image — `createImage()`

1. Create an `<img>` with class `trail-img`; pick a **random** URL from the pool.
2. Random rotation: `(Math.random() - 0.5) * 50` → uniform in **−25°…+25°**.
3. Position it relative to the container: `left = mouseX - rect.left`, `top = mouseY - rect.top` (rect from `getBoundingClientRect()`), in px.
4. Initial inline transform: `translate(-50%, -50%) rotate(<r>deg) scale(0)`, with inline `transition: transform 750ms cubic-bezier(.07,.5,.5,1)`.
5. Append to the container, then after a **10 ms** `setTimeout` set the transform to the same translate/rotate but `scale(1)` so the transition plays (scale 0 → 1 pop-in; rotation stays fixed for the image's whole life).
6. Push `{ element, rotation, removeTime: Date.now() + 750 }` onto the `trail` queue.

### Per-frame spawner — `createTrailImage()` (called every rAF)

- Bail if the cursor isn't inside the container.
- If `isMoving` **and** the Euclidean distance from `lastMouseX/Y` to `mouseX/Y` exceeds 100 px: update `lastMouseX/Y` to the current position and spawn.
- Else if **not** moving and `now - lastSteadyImageTime >= 300` ms: update `lastSteadyImageTime` and spawn (a resting cursor keeps emitting a gentle pulse of images every 300 ms).

### Scroll-driven spawner — `createScrollTrailImage()`

Bail if the cursor isn't in the container. Otherwise fake enough travel to force a spawn: offset `lastMouseX` **and** `lastMouseY` each by `(mouseThreshold + 10)` px with an independent random sign (±110), call `createImage()`, then reset `lastMouseX/Y` back to the real `mouseX/Y`.

### Removal — `removeOldImages()` (called every rAF)

- Throttle: return if `now - lastRemovalTime < 50` ms or the queue is empty.
- Only inspect the **oldest** entry (`trail[0]`). If `now >= removeTime`, shift it off the queue, overwrite its inline transition to `transform 1000ms cubic-bezier(.87,0,.13,1)`, set its transform to `translate(-50%, -50%) rotate(<same r>deg) scale(0)`, record `lastRemovalTime`, and remove the element from the DOM via `setTimeout` after 1000 ms (guard that it still has a parent). So images die strictly FIFO, at most one every 50 ms.

### Event wiring

- `document mousemove`: update `mouseX/Y` and `isCursorInContainer`. When inside the container set `isMoving = true` and debounce it back to `false` after **100 ms** of no movement (clear/reset a timeout each event).
- `window scroll` (passive) — listener 1: recompute `isCursorInContainer` from the stored mouse position; if inside, set `isMoving = true`, nudge `lastMouseX` by `(Math.random() - 0.5) * 10`, and debounce `isMoving = false` after 100 ms.
- `window scroll` (passive) — listener 2: set `isScrolling = true`; throttle to one pass per **50 ms** (`lastScrollTime`); use a `scrollTicking` flag so only one `requestAnimationFrame` callback is queued at a time — inside it, if still scrolling, call `createScrollTrailImage()` and clear `isScrolling`, then release the tick flag.
- Main loop: `const animate = () => { createTrailImage(); removeOldImages(); requestAnimationFrame(animate); }; animate();`
- Smooth scroll: `new Lenis({ autoRaf: true })` — no other Lenis config.

## Assets / images

35 editorial fashion/beauty portraits (moody studio photography — dramatic lighting, bold styling, mixed dark and vividly colored backdrops). Any source aspect works since each is cropped to a 200×200 square by `object-fit: cover`. Reference them as `img1.jpeg` … `img35.jpeg` (adjust paths/extensions to whatever assets are available).

## Behavior notes

- Desktop / pointer-driven experience: without a mouse nothing spawns (no touch handling).
- The trail self-cleans: with a 750 ms lifespan, ~1 s exit and FIFO removal throttled at 50 ms, a fast-moving cursor keeps roughly a dozen images alive at once.
- Because images are absolutely positioned children of the section with `overflow: hidden`, they scroll with the panel and clip at its edges.
- No console errors on load; the effect needs no user interaction to initialize, only to display.
