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>
.itemsstarts empty — every tile is created in JS at runtime.- The
<h1>copy isMove your mouse/to explorewith 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 bothhtmlandbody. - 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-serifis fine. What matters visually: 40px, weight 400, uppercase, tightline-height: 100%, centered. - Stacking:
.items(z-index: 2) renders over the<h1>(z-index: 1), so spawned tiles cover the headline. - Tile box: every
.itemis a fixed 150 × 200 px (3:4 portrait) black rectangle withoverflow: hidden; the<img>fills it withobject-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 - 75centers the 150px-wide tile horizontally under the cursor.top = y - 75uses 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. Useevent.pageX/event.pageYforx/y. - Image cycling:
imageIndexruns 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):
y0 → 1000(translate 1000px straight down, well off-screen),scale1 → 0.5(shrink to half),opacity1 → 0(fade out). All three run concurrently on each tile. duration: 0.5seconds 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.outapplies. onComplete: iteratesthis.targets()and removes each animated tile from the DOM, then clearscurrentlyAnimating = false. (Tiles spawned *after* this tween was created are not part ofthis.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), notdocument/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
startAnimationnever 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:
- Minimalist lone figure on a reflective wet surface with a diagonal shaft of light, teal/cyan tones.
- Close-up of a golden reflective astronaut helmet against a dark green backdrop.
- Lone astronaut silhouette walking through foggy golden desert, large glowing sun behind.
- Dark moody portrait of a woman, hands raised to her face in low light with jewelry.
- Silhouette pressing a hand against frosted glass, warm orange backlight.
- Moody studio portrait of a blonde woman in black against a dark background.
- Figure in a wide decorative conical hat and futuristic sunglasses, teal/lavender tones.
- Small golden lucky-cat figurine inside a red circle on plain white.
- Person in a black suit, head obscured by white feathers/fabric, grey background.
- Golden-hour city skyline silhouette under a warm orange sky.
- Soft-lit figure with wet hair leaning to one side, pale blue tones.
- Green-lit muscular figure sculpture amid soft bokeh light spots.
- Still life of pink roses and green leaves in an S-shape on black.
- Portrait of a woman with sleek dark hair in large futuristic visor sunglasses, warm brown backdrop.
- 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-index2 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, defaultpower1.outease, then removes the tiles. - Console is clean (zero errors); only
gsapis imported, no plugins.