All components

Mouse Image Trail

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A cursor-driven image trail: moving the mouse spawns positioned image tiles that follow the pointer, and after a brief pause of no movement GSAP animates the whole batch downward with a staggered scale-and-fade drop before removing them. Purely hover/mousemove triggered using gsap.to with y, scale, opacity and a 0.025s stagger.

How it's built

Categoryhover
Techgsap
Complexitypage
Performance costlight
Mobile-safedesktop-first

cursor trail mousemove image-trail stagger gsap interactive playful

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Mouse Image Trail — cursor spawns image tiles that drop-and-fade on pause

Goal

Build a full-viewport interactive canvas where moving the mouse spawns image tiles at the cursor, leaving a trail of stacked photos across the screen. The tiles keep piling up while the pointer is moving; the instant the pointer pauses (~100 ms of no movement) GSAP animates the entire batch straight down off-screen with a staggered scale-down + fade, then removes them. A centered uppercase headline sits behind the trail. The star effect is the gsap.to(".item", …) staggered drop (y: 1000, scale: 0.5, opacity: 0) fired on pointer-idle.

Tech

Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite + npm project. Install and import from npm:

  • gsap (3.x) — the only dependency.
import gsap from "gsap";

No GSAP plugins (no ScrollTrigger, SplitText, CustomEase), no Lenis, no Three.js, no canvas/WebGL. All logic runs inside a single DOMContentLoaded listener. Ship exactly three files: index.html, styles.css, script.js.

Layout / HTML

A minimal document: one empty .items layer (JS injects tiles into it) and one headline <h1>. Class names are load-bearing — the JS queries .items and animates .item; the CSS positions both.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Mouse Image Trail</title>
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <div class="items"></div>
    <h1>
      Move your mouse <br />
      to explore
    </h1>
    <script type="module" src="./script.js"></script>
  </body>
</html>
  • .items starts empty — every tile is created in JS at runtime.
  • The <h1> copy is Move your mouse / to explore with a <br> between the two lines. Neutral demo text, no brands.

Styling

* { margin: 0; padding: 0; box-sizing: border-box; }

html, body { background-color: orange; }   /* CSS keyword orange = #ffa500 */

h1 {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  font-family: "PP Neue Montreal";
  font-size: 40px;
  font-weight: 400;
  text-transform: uppercase;
  line-height: 100%;
  z-index: 1;
}

.items {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 2;      /* the trail layer sits ABOVE the headline */
}

.item {
  position: absolute;
  width: 150px;
  height: 200px;
  background: #000;   /* black backing shows behind the image while it loads */
  overflow: hidden;   /* clips the cover-cropped image to the tile box */
}

.item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Details that matter:

  • Background is the CSS keyword orange (#ffa500) on both html and body.
  • Font: "PP Neue Montreal" — a modern grotesque. It is not a public web font and will silently fall back; keep it as the first name but a stack like "PP Neue Montreal", "Helvetica Neue", Arial, sans-serif is fine. What matters visually: 40px, weight 400, uppercase, tight line-height: 100%, centered.
  • Stacking: .items (z-index: 2) renders over the <h1> (z-index: 1), so spawned tiles cover the headline.
  • Tile box: every .item is a fixed 150 × 200 px (3:4 portrait) black rectangle with overflow: hidden; the <img> fills it with object-fit: cover.

GSAP effect (the important part — be exhaustive)

Everything is pointer-driven (mousemove) — there is no load, scroll, hover-enter or click animation. Wrap all of it in document.addEventListener("DOMContentLoaded", …).

State (module-scope inside the DOMContentLoaded callback)
const container = document.querySelector(".items");
let imageIndex = 1;            // 1-based, cycles 1..15
let animationTimeout = null;   // the pending "idle" timer
let currentlyAnimating = false;
1. Spawning a tile — addNewItem(x, y)

On each pointer move, create and position a fresh tile at the cursor:

function addNewItem(x, y) {
  const newItem = document.createElement("div");
  newItem.className = "item";
  newItem.style.left = `${x - 75}px`;   // 150px wide → -75 centers HORIZONTALLY on cursor
  newItem.style.top  = `${y - 75}px`;   // NOT vertically centered (tile is 200px tall)

  const img = document.createElement("img");
  img.src = `img${imageIndex}.jpg`;     // cycles through img1.jpg … img15.jpg
  newItem.appendChild(img);
  imageIndex = (imageIndex % 15) + 1;   // 1,2,…,15,1,2,… (wraps after 15)

  container.appendChild(newItem);
  manageItemLimit();
}
  • Positioning: left = x - 75 centers the 150px-wide tile horizontally under the cursor. top = y - 75 uses the *same* 75px offset even though the tile is 200px tall, so the cursor sits 75px below the tile's top edge (125px above its bottom) — the tile hangs mostly *below/around* the pointer, intentionally not vertically centered. Use event.pageX / event.pageY for x / y.
  • Image cycling: imageIndex runs 1→15 and wraps via (imageIndex % 15) + 1; each new tile takes the next image in sequence, so a fast sweep lays down all 15 in order before repeating.
2. Capping the trail — manageItemLimit()
function manageItemLimit() {
  while (container.children.length > 20) {
    container.removeChild(container.firstChild);   // drop the OLDEST tile
  }
}

At most 20 tiles exist at once; while the pointer keeps moving, the oldest tiles are silently removed from the front so the trail's length stays bounded.

3. The drop — startAnimation() (the signature GSAP tween)
function startAnimation() {
  if (currentlyAnimating || container.children.length === 0) return;
  currentlyAnimating = true;
  gsap.to(".item", {
    y: 1000,
    scale: 0.5,
    opacity: 0,
    duration: 0.5,
    stagger: 0.025,
    onComplete: function () {
      this.targets().forEach((item) => {
        if (item.parentNode) item.parentNode.removeChild(item);
      });
      currentlyAnimating = false;
    },
  });
}

Exact spec:

  • Targets: the selector ".item" — GSAP resolves it to all tiles present at call time, animating the whole current batch together.
  • Properties animated (from current → to): y 0 → 1000 (translate 1000px straight down, well off-screen), scale 1 → 0.5 (shrink to half), opacity 1 → 0 (fade out). All three run concurrently on each tile.
  • duration: 0.5 seconds per tile.
  • stagger: 0.025 — each tile starts 25 ms after the previous one, in DOM order (oldest first), so the batch cascades downward rather than dropping in unison.
  • Ease: none is passed, so GSAP's default power1.out applies.
  • onComplete: iterates this.targets() and removes each animated tile from the DOM, then clears currentlyAnimating = false. (Tiles spawned *after* this tween was created are not part of this.targets() and survive to the next drop.)
  • Guards: returns early if a drop is already running (currentlyAnimating) or there is nothing to animate (container.children.length === 0), so overlapping/empty tweens never start.
4. The trigger — mousemove on the container
container.addEventListener("mousemove", function (event) {
  clearTimeout(animationTimeout);
  addNewItem(event.pageX, event.pageY);
  animationTimeout = setTimeout(startAnimation, 100);
});

This is the whole interaction loop, and the timing is the effect:

  • The listener is on .items (the fixed full-viewport layer), not document/window.
  • Every move (a) clears the previously-scheduled idle timer, (b) spawns a tile at the cursor, (c) re-arms a 100 ms setTimeout(startAnimation, 100).
  • While the pointer keeps moving (events < 100 ms apart), the timer is cleared and reset on every event, so startAnimation never fires — tiles just accumulate (capped at 20) and trail the cursor.
  • The moment the pointer pauses for ≥ 100 ms, the pending timer finally fires → startAnimation() drops the whole batch. So the trail lives while you move and evaporates the instant you stop.

Assets / images

15 portrait images referenced as img1.jpg … img15.jpg, each shown inside a 150 × 200 px (3:4) tile with object-fit: cover (framing is forgiving). Cinematic / experimental / editorial mood, coherent as a set. Roles by index:

  1. Minimalist lone figure on a reflective wet surface with a diagonal shaft of light, teal/cyan tones.
  2. Close-up of a golden reflective astronaut helmet against a dark green backdrop.
  3. Lone astronaut silhouette walking through foggy golden desert, large glowing sun behind.
  4. Dark moody portrait of a woman, hands raised to her face in low light with jewelry.
  5. Silhouette pressing a hand against frosted glass, warm orange backlight.
  6. Moody studio portrait of a blonde woman in black against a dark background.
  7. Figure in a wide decorative conical hat and futuristic sunglasses, teal/lavender tones.
  8. Small golden lucky-cat figurine inside a red circle on plain white.
  9. Person in a black suit, head obscured by white feathers/fabric, grey background.
  10. Golden-hour city skyline silhouette under a warm orange sky.
  11. Soft-lit figure with wet hair leaning to one side, pale blue tones.
  12. Green-lit muscular figure sculpture amid soft bokeh light spots.
  13. Still life of pink roses and green leaves in an S-shape on black.
  14. Portrait of a woman with sleek dark hair in large futuristic visor sunglasses, warm brown backdrop.
  15. Woman sitting on a paddleboard on calm water at dusk.

No brands or logos. If fewer than 15 real images are available, any consistent set of portrait-oriented photos works; the effect does not depend on the specific subjects.

Behavior notes

  • Desktop / mouse only — the whole thing is mousemove-driven with no touch fallback; leave it as-is (pointer-first).
  • No reduced-motion guard in the original.
  • The trail self-limits to 20 live tiles; each pause fires exactly one staggered drop that cleans itself up in onComplete.
  • Tiles render above the centered headline (z-index 2 vs 1), so a busy trail temporarily hides the "Move your mouse to explore" text.

Acceptance check (reproduction is faithful only if all hold)

  • Full-orange (#ffa500) viewport with a centered, uppercase, 40px "Move your mouse / to explore" headline visible at rest (empty .items).
  • Moving the mouse spawns 150×200 black-backed image tiles at the cursor (horizontally centered on it), cycling through 15 images and trailing the pointer, capped at ~20 on screen.
  • Pausing the pointer ~100 ms triggers one gsap.to(".item", …) that sends the whole batch down (y: 1000) while scaling to 0.5 and fading to 0, staggered 0.025s, 0.5s each, default power1.out ease, then removes the tiles.
  • Console is clean (zero errors); only gsap is imported, no plugins.