# Cursor Image Trail with Striped Clip-Path Reveal (dark editorial hero)

## Goal
Build a full-viewport dark hero where **moving the mouse leaves a trail of 175×175px editorial portrait images: each time the cursor travels past a distance threshold, a new image spawns at a smoothed (lerped) trailing position and slides toward the live cursor position while it is revealed through 10 horizontal strip masks that expand from a center line outward with a middle-first stagger; ~1 second later the strips collapse back to the center (edges first) while the image dims, and the element is removed**. The star effect is the striped clip-path in/out reveal combined with the lerp-lagged spawn-and-slide motion.

## Tech
Vanilla HTML/CSS/JS with an ES module script (`<script type="module" src="./script.js">`). **No GSAP, no plugins, no Lenis, no libraries at all** — the whole effect is native CSS `transition`s (on `clip-path`, `left`, `top`, `opacity`) driven by a `requestAnimationFrame` loop, `mousemove` tracking, manual linear interpolation (lerp), and `setTimeout`-based staggers. There is no scroll interaction.

## Layout / HTML
```
section.hero                      (full-viewport stage)
  .hero-img > img                 (full-bleed faint background image)
  p                               "[ The Future Moves in Frames ]"
  p                               "Experiment 457 by Motionprompts"
  .trail-container                (empty; JS appends the trail images here)
```
- `.trail-container` starts **empty** — JS creates every `.trail-img` element on the fly.
- Each spawned trail element has this exact runtime structure (built by JS):
```
.trail-img                        (175×175 absolutely positioned box)
  .mask-layer  × 10               (stacked full-size layers, one per 10%-tall horizontal strip)
    .image-layer                  (full-size div with the portrait as background-image)
```

## Styling
Font: **IBM Plex Mono** (Google Fonts `@import`, all weights 100–700 + italics available; regular is what's used).

- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { background: #101010; }` (near-black)
- `img { width:100%; height:100%; object-fit:cover; }`
- `p { color:#4e4e4e; text-transform:uppercase; font-family:"IBM Plex Mono"; font-size:0.85rem; }` — dim grey mono captions.
- `.hero`: `position:relative; width:100vw; height:100svh; display:flex; flex-direction:column; justify-content:center; align-items:center; overflow:hidden;` — the two `<p>` lines end up stacked dead-center.
- `.hero-img`: `position:absolute; width:100%; height:100%; opacity:0.2;` — the background image is only 20% visible, keeping the page near-black.
- `.trail-container`: `position:absolute; width:100%; height:100%; overflow:hidden; z-index:2;` — sits above the text/background and hosts the trail.
- `.trail-img`: `position:absolute; width:175px; height:175px; pointer-events:none;`
- `.trail-img .mask-layer`: `position:absolute; top:0; left:0; width:100%; height:100%; background-color:#000000; will-change:clip-path;`
- `.trail-img .mask-layer .image-layer`: `position:absolute; top:0; left:0; width:100%; height:100%; background-size:cover; background-position:center;`

## The effect (exhaustive — vanilla JS, CSS transitions, rAF)

### Config constants (use these exact values)
```js
const config = {
  imageLifespan: 1000,        // ms an image lives before its out-animation starts
  mouseThreshold: 150,        // px the cursor must travel to spawn the next image
  inDuration: 750,            // ms for the strip-reveal clip-path transition
  outDuration: 1000,          // ms for the strip-collapse + fade-out transitions
  staggerIn: 100,             // ms per strip-distance unit on the way IN
  staggerOut: 25,             // ms per strip-distance unit on the way OUT
  slideDuration: 1000,        // ms for the left/top slide toward the cursor
  slideEasing: "cubic-bezier(0.25, 0.46, 0.45, 0.94)",  // gentle ease-out for the slide
  easing: "cubic-bezier(0.87, 0, 0.13, 1)",             // aggressive ease-in-out for all clip-path/opacity moves
};
const trailImageCount = 20;   // 20 image URLs, cycled round-robin with an index modulo 20
```

### State & helpers
- `trail = []` (FIFO queue of live images), `currentImageIndex = 0`, `mousePos {x,y}`, `lastMousePos {x,y}` (position at the last spawn), `interpolatedMousePos {x,y}` (the lerped/lagged cursor), `isDesktop = window.innerWidth > 1000`.
- `lerp(a, b, n) = (1 - n) * a + n * b`; distance via `Math.hypot`.
- `isInTrailContainer(x, y)` — bounds check of the raw mouse position against `trailContainer.getBoundingClientRect()`.
- Mouse tracking: `document.addEventListener("mousemove", e => { mousePos.x = e.clientX; mousePos.y = e.clientY; })`.

### rAF render loop (runs every frame while desktop)
1. `distance = Math.hypot(mousePos - lastMousePos)` (distance traveled since the last spawn).
2. Lerp the lagged cursor with **factor 0.1**:
   `interpolatedMousePos.x = lerp(interpolatedMousePos.x || mousePos.x, mousePos.x, 0.1)` (same for `y`; the `|| mousePos.x` fallback seeds it on the first frames).
3. If `distance > config.mouseThreshold` **and** the raw mouse is inside the trail container → `createTrailImage()` and `lastMousePos = { ...mousePos }`.
4. `removeOldImages()`.
5. `requestAnimationFrame(render)` again.

### `createTrailImage()` — spawn + slide + striped reveal
1. Create `div.trail-img`; pick `images[currentImageIndex]` then `currentImageIndex = (currentImageIndex + 1) % 20`.
2. Positions are container-relative and centered on the point (offset by **87.5** = half of 175):
   - `startX/Y = interpolatedMousePos − containerRect.left/top − 87.5` (the **lagged** position — this is where the image appears),
   - `targetX/Y = mousePos − containerRect.left/top − 87.5` (the **live** cursor position — this is where it slides to).
3. Set inline `left/top` to the start position and
   `transition: left 1000ms cubic-bezier(0.25,0.46,0.45,0.94), top 1000ms cubic-bezier(0.25,0.46,0.45,0.94)` (i.e. `slideDuration` + `slideEasing`).
4. Build **10 mask layers** (`i = 0…9`). For each:
   - `startY = i * 10`, `endY = (i + 1) * 10` (each layer owns one 10%-tall horizontal strip).
   - Inner `div.image-layer` with `background-image: url(imgSrc)` (all 10 layers use the SAME image; each strip just reveals its own slice because the parent clip crops it).
   - Initial **closed** clip: `clip-path: polygon(50% startY%, 50% startY%, 50% endY%, 50% endY%)` — a zero-width vertical sliver at the horizontal center of the strip.
   - `transition: clip-path 750ms cubic-bezier(0.87,0,0.13,1)` (`inDuration` + `easing`), plus GPU hints `transform: translateZ(0)` and `backface-visibility: hidden`.
5. Append the container to `.trail-container`, then inside a `requestAnimationFrame` callback (so initial styles commit first):
   - set `left/top` to the target position (kicks off the 1s slide toward the cursor),
   - for each layer `i`, after `delay = Math.abs(i - 4.5) * config.staggerIn` (via `setTimeout`), set the **open** clip:
     `clip-path: polygon(0% startY%, 100% startY%, 100% endY%, 0% endY%)` — the strip expands to full width.
     Distance-from-middle stagger: the two middle strips (i=4,5 → 0.5 × 100 = 50ms) open first, the outermost (i=0,9 → 4.5 × 100 = 450ms) open last → the image "blooms" open from its vertical center outward.
6. Push `{ element, maskLayers, imageLayers, removeTime: Date.now() + config.imageLifespan }` onto the `trail` queue.

### `removeOldImages()` — striped collapse + dim + cleanup
Each frame, check only `trail[0]` (the oldest). When `Date.now() >= removeTime`, shift it off the queue and:
- For each mask layer `i`: re-set `transition: clip-path 1000ms cubic-bezier(0.87,0,0.13,1)` (`outDuration`), then after `delay = (4.5 - Math.abs(i - 4.5)) * config.staggerOut` (via `setTimeout`) set the clip back to the **closed** center-sliver polygon. Note the inverted stagger: **edge strips (i=0,9 → 0ms) collapse first, middle strips last (i=4,5 → 4 × 25 = 100ms)** — the reverse of the reveal.
- Simultaneously, every `.image-layer` gets `transition: opacity 1000ms cubic-bezier(0.87,0,0.13,1)` and `opacity: 0.25` — the image dims as its strips close.
- Remove the element from the DOM after `config.outDuration + 112` ms (`setTimeout`).

### Desktop gate & lifecycle
- The whole effect only runs when `window.innerWidth > 1000`. `startAnimation()` attaches the `mousemove` listener and starts the rAF loop (and returns a cleanup fn that removes the listener); `stopAnimation()` cancels the rAF and removes every live trail element (clearing the queue).
- On `window.resize`, detect the boundary crossing: mobile→desktop calls `startAnimation()`, desktop→mobile calls `stopAnimation()` plus the mousemove cleanup.
- **Declaration order matters:** wrap the entire effect in a single `init` function, and inside it declare ALL state (config, `trail`, `currentImageIndex`, mouse position objects, the `isDesktop` flag, the rAF id variable, the `cleanUpMouseListener` reference) **before** defining/wiring any listeners or calling `startAnimation()`. Nothing outside `init` may reference these variables — this prevents any use-before-declaration (TDZ) crash.
- **Bootstrapping (use this exact pattern, nothing else):** define `init` fully first, then at the very bottom of the module a single branch:
  ```js
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
  ```
  Do NOT also call `init()` at top level, and do NOT add an "already initialized" guard flag — exactly one branch of the `if/else` runs, so a guard is unnecessary (and a flag declared after a top-level call is a fatal TDZ `ReferenceError`). Since the script is an ES module (deferred), the `else` branch is what normally runs.

## Assets / images
**21 images total:**
- **1 hero background** (`.hero-img > img`, full-bleed landscape ~16:9): a studio editorial fashion portrait in the same family as the 20 trail portraits — a woman in a dark red coat and matching red blouse, photographed against a vivid cobalt-blue seamless backdrop, framed off-center with generous blue negative space. It renders at 20% opacity over the `#101010` body, so on the page it reads as a faint, dark navy-tinted scene with a barely-visible figure — NOT a bright image.
- **20 trail portraits** (roughly square, displayed as 175×175 with `background-size: cover`): bold, high-fashion studio editorial portraits of diverse models — saturated mono-color backdrops (red, yellow, blue, magenta, orange, grey), dramatic colored lighting, sunglasses, sculptural styling. Visually punchy crops that read instantly at 175px. They are cycled round-robin, so variety between consecutive images matters.

No real brands or client names anywhere; the two caption lines are the neutral demo strings given in the Layout section.

## Behavior notes
- **Desktop-only** (>1000px viewport width); on smaller screens nothing spawns and any live trail is cleared.
- No `prefers-reduced-motion` branch, no scroll behavior, no infinite loop other than the rAF watcher.
- Spawn cadence is governed purely by cursor travel distance (150px), not time — fast sweeps produce a dense trail, slow movement produces none.
- The lerp factor 0.1 makes the spawn point trail noticeably behind the cursor, so every new image visibly slides ~toward the cursor over 1s while it opens.
- Trail elements never intercept pointer events (`pointer-events: none`), and `.trail-container`'s `overflow: hidden` clips images spawned near the edges.
