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 transitions (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-containerstarts empty — JS creates every.trail-imgelement 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)
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 viaMath.hypot.isInTrailContainer(x, y)— bounds check of the raw mouse position againsttrailContainer.getBoundingClientRect().- Mouse tracking:
document.addEventListener("mousemove", e => { mousePos.x = e.clientX; mousePos.y = e.clientY; }).
rAF render loop (runs every frame while desktop)
distance = Math.hypot(mousePos - lastMousePos)(distance traveled since the last spawn).- 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).
- If
distance > config.mouseThresholdand the raw mouse is inside the trail container →createTrailImage()andlastMousePos = { ...mousePos }. removeOldImages().requestAnimationFrame(render)again.
createTrailImage() — spawn + slide + striped reveal
- Create
div.trail-img; pickimages[currentImageIndex]thencurrentImageIndex = (currentImageIndex + 1) % 20. - 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).- Set inline
left/topto 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).
- 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-layerwithbackground-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 hintstransform: translateZ(0)andbackface-visibility: hidden.- Append the container to
.trail-container, then inside arequestAnimationFramecallback (so initial styles commit first): - set
left/topto the target position (kicks off the 1s slide toward the cursor), - for each layer
i, afterdelay = Math.abs(i - 4.5) * config.staggerIn(viasetTimeout), 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.
- Push
{ element, maskLayers, imageLayers, removeTime: Date.now() + config.imageLifespan }onto thetrailqueue.
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-settransition: clip-path 1000ms cubic-bezier(0.87,0,0.13,1)(outDuration), then afterdelay = (4.5 - Math.abs(i - 4.5)) * config.staggerOut(viasetTimeout) 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-layergetstransition: opacity 1000ms cubic-bezier(0.87,0,0.13,1)andopacity: 0.25— the image dims as its strips close. - Remove the element from the DOM after
config.outDuration + 112ms (setTimeout).
Desktop gate & lifecycle
- The whole effect only runs when
window.innerWidth > 1000.startAnimation()attaches themousemovelistener 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 callsstartAnimation(), desktop→mobile callsstopAnimation()plus the mousemove cleanup. - Declaration order matters: wrap the entire effect in a single
initfunction, and inside it declare ALL state (config,trail,currentImageIndex, mouse position objects, theisDesktopflag, the rAF id variable, thecleanUpMouseListenerreference) before defining/wiring any listeners or callingstartAnimation(). Nothing outsideinitmay reference these variables — this prevents any use-before-declaration (TDZ) crash. - Bootstrapping (use this exact pattern, nothing else): define
initfully 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#101010body, 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-motionbranch, 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'soverflow: hiddenclips images spawned near the edges.