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.
Images
This component ships with 15 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/img-trail/img1.jpg
https://motionprompts.dev/c/img-trail/img10.jpg
https://motionprompts.dev/c/img-trail/img11.jpg
https://motionprompts.dev/c/img-trail/img12.jpg
https://motionprompts.dev/c/img-trail/img13.jpg
https://motionprompts.dev/c/img-trail/img14.jpg
… 9 more under https://motionprompts.dev/c/img-trail/
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--field,--ink,--paper,--ink-soft. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that reaches into the page with document.querySelector(".items"), then keeps a mousemove listener alive for as long as the page is open, spawning and deleting .item nodes by hand outside anything React tracks. React withdraws all three of those guarantees at once, and it does it quietly — the trail keeps following the cursor, but something underneath is now doubled.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that means two mousemove listeners bound to the same .items element, each calling its own closure's addNewItem: every pixel of cursor movement spawns two tiles instead of one, blows through manageItemLimit's cap of twenty at twice the rate, and arms two independent animationTimeout timers. When the pointer stops, both timers eventually fire startAnimation, each behind its own currentlyAnimating flag — so it is possible to get two overlapping gsap.to(".item", …) drops instead of one clean batch. The symptom is a denser, jumpier trail that drops twice, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not an afterthought.
*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the trail never activates — no error, nothing to debug, just a headline that never grows tiles. Delete the listener and move its entire body — the imageIndex / animationTimeout / currentlyAnimating state, addNewItem, manageItemLimit, startAnimation, and the mousemove binding — directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — The one document.querySelector(".items") assumes this component owns the document. Give the component a root ref, render the .items layer inside it, and look the container up from that ref instead of from document. During the StrictMode remount two .items elements exist for an instant; an unscoped lookup can bind the trail's listener to the copy that's on its way out.
*(3) Cleanup* — Wrap addNewItem, manageItemLimit, startAnimation and the mousemove binding in a gsap.context scoped to the root ref, and revert that context in the cleanup:
useEffect(() => {
const ctx = gsap.context(() => {
/* addNewItem, manageItemLimit, startAnimation, and the mousemove listener, unchanged */
}, rootRef);
return () => ctx.revert();
}, []);
ctx.revert() only undoes what GSAP itself created — tweens, triggers, and the inline styles they wrote. It does not touch either of the two things this effect adds by hand:
- The
mousemovelistener.container.addEventListener("mousemove", …)is not a GSAP call, so the context never records it. Keep the handler in a named variable and callcontainer.removeEventListener("mousemove", handler)in the same cleanup that reverts the context — otherwise the stale instance from a StrictMode unmount keeps listening on a container that still exists in the DOM and keeps spawning tiles into it. - The pending
animationTimeout. This is a plainsetTimeout, not a tween, so the context doesn't know it exists either. If the component unmounts in the roughly one-tenth-of-a-second window between the lastmousemoveand the idle fire, the scheduledstartAnimationruns afterctx.revert()has already run, against a listener that's gone and a context that no longer tracks anything it creates. Capture the timer id in the same scope the cleanup closes over and callclearTimeouton it there, in addition to theclearTimeoutaddNewItem's handler already does on every move.
Two more things follow from how this specific effect is built, not from the general React rules above:
Don't promote imageIndex, animationTimeout, or currentlyAnimating to useState. All three are read and written exclusively by functions declared inside this same effect — nothing in the render path or JSX ever needs their current value. A useState setter firing on every mousemove would re-render the component at cursor-tracking frequency for no visual benefit, since the tiles themselves are never rendered by React (see below); a plain variable closed over by the effect's functions, or a ref if you split those functions out, does the job with none of that cost.
Don't lift the .item tiles into React state and render them with .map(). addNewItem and manageItemLimit's direct createElement / appendChild / removeChild calls, and startAnimation's onComplete walking this.targets() to remove the exact nodes GSAP just animated, are the reason this runs as fast as a raw mousemove handler needs to. Modeling the trail as an array of tile objects and letting React reconcile them on every pointer event reintroduces the render churn this design avoids, and it races GSAP: a re-render can swap out the DOM node this.targets() is mid-tween on before onComplete gets to remove it.
gsap.to(".item", …) is a live class selector, not a captured reference — this is exactly what gsap.context's selector scoping is for. Inside a context, GSAP resolves string selectors against the context's own root instead of the whole document, so .item only ever matches tiles this component's own .items container holds. That is not incidental cleanup bookkeeping here: without it, a second instance of this component elsewhere on the page — or the two copies that briefly coexist during a StrictMode remount — would have their gsap.to(".item", …) calls sweep up each other's tiles into a single shared drop.