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()
- Create an
<img>with classtrail-img; pick a random URL from the pool. - Random rotation:
(Math.random() - 0.5) * 50→ uniform in −25°…+25°. - Position it relative to the container:
left = mouseX - rect.left,top = mouseY - rect.top(rect fromgetBoundingClientRect()), in px. - Initial inline transform:
translate(-50%, -50%) rotate(<r>deg) scale(0), with inlinetransition: transform 750ms cubic-bezier(.07,.5,.5,1). - Append to the container, then after a 10 ms
setTimeoutset the transform to the same translate/rotate butscale(1)so the transition plays (scale 0 → 1 pop-in; rotation stays fixed for the image's whole life). - Push
{ element, rotation, removeTime: Date.now() + 750 }onto thetrailqueue.
Per-frame spawner — createTrailImage() (called every rAF)
- Bail if the cursor isn't inside the container.
- If
isMovingand the Euclidean distance fromlastMouseX/YtomouseX/Yexceeds 100 px: updatelastMouseX/Yto the current position and spawn. - Else if not moving and
now - lastSteadyImageTime >= 300ms: updatelastSteadyImageTimeand 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 < 50ms or the queue is empty. - Only inspect the oldest entry (
trail[0]). Ifnow >= removeTime, shift it off the queue, overwrite its inline transition totransform 1000ms cubic-bezier(.87,0,.13,1), set its transform totranslate(-50%, -50%) rotate(<same r>deg) scale(0), recordlastRemovalTime, and remove the element from the DOM viasetTimeoutafter 1000 ms (guard that it still has a parent). So images die strictly FIFO, at most one every 50 ms.
Event wiring
document mousemove: updatemouseX/YandisCursorInContainer. When inside the container setisMoving = trueand debounce it back tofalseafter 100 ms of no movement (clear/reset a timeout each event).window scroll(passive) — listener 1: recomputeisCursorInContainerfrom the stored mouse position; if inside, setisMoving = true, nudgelastMouseXby(Math.random() - 0.5) * 10, and debounceisMoving = falseafter 100 ms.window scroll(passive) — listener 2: setisScrolling = true; throttle to one pass per 50 ms (lastScrollTime); use ascrollTickingflag so only onerequestAnimationFramecallback is queued at a time — inside it, if still scrolling, callcreateScrollTrailImage()and clearisScrolling, 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.