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.4;— the background plate is heavily knocked back, keeping the page near-black. Tune this to the plate you actually use (see Assets): the value only has to leave the haze visible without lifting the ground..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): an abstract, subjectless plate — lit studio haze, diagonal shafts of light raking through smoke over near-black, with a warm ochre glow low-left. It renders at 40% opacity over the near-black body, so on the page it reads as a deep prussian-blue atmosphere with faint shafts in it. Do not use a portrait here. A face in the background competes with the twenty portraits streaming over it: the eye keeps trying to read the big blurry figure instead of the small sharp ones, and the headline ends up sitting on a cheek. The background's job is to give the trail something to be *lit against* — texture and direction, no subject. Anything with a recognisable subject (a person, a product, a building) fails that job, however faint you make it. Mark italt="": it is decoration, not content. - Two consequences of choosing a plate instead of a photo of something: crop it dead centre (
object-position: 50% 50%) at every aspect ratio, since there is nothing to keep in frame; and keep the mobile de-blocking blur light (blur(1px), not 2–3px) — smoke is fine wisps and a heavy blur flattens the whole thing into a grey wash. Choose the opacity by measurement, not habit: aim for the headline colour to clear ~5:1 against the brightest pixel it can land on. A darker plate needs a *higher* opacity than a bright one to stay visible at all. - 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.
Images
This component ships with 21 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/247artists-trail/hero.jpg
https://motionprompts.dev/c/247artists-trail/trail-images/img1.jpeg
https://motionprompts.dev/c/247artists-trail/trail-images/img10.jpeg
https://motionprompts.dev/c/247artists-trail/trail-images/img11.jpeg
https://motionprompts.dev/c/247artists-trail/trail-images/img12.jpeg
https://motionprompts.dev/c/247artists-trail/trail-images/img13.jpeg
… 15 more under https://motionprompts.dev/c/247artists-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—--ink,--ink-deep,--paper,--crimson,--slate,--line,--serif,--mono,--pad. 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
*,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.
Adapting this to React
Everything above describes a standalone document: a script that reaches into the page with a single document.querySelector(".trail-container"), then keeps a pointermove listener and a self-recursing requestAnimationFrame loop alive for as long as the page is open. React withdraws all of that 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. Setup that runs twice with teardown that never runs leaves two of everything here: two pointermove listeners each writing to their own mousePos, two render loops independently lerping their own interpolatedMousePos and independently deciding to call createTrailImage() against the same .trail-container once the same swipe crosses the distance threshold — so one gesture spawns twice the images it should, and both copies keep fighting over the same DOM node long after only one of them should exist. The symptom is a doubled or overlapping trail, 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* — This script is already closer to React's shape than most in this catalogue: the original top-level init() has been rewritten as mount(config), returning a destroy() that undoes exactly what mount() built — that split exists so this catalogue's own editor runtime (window.MP.register) can tear the effect down and remount it live with a different config. Drop the window.MP branch entirely; it is the editor's hook into this file, not something a consuming app ships. Drop the document.readyState check and its DOMContentLoaded listener too — that guard exists to survive being loaded late into a plain document, and useEffect already runs after the DOM is committed, so there is nothing here for it to guard against. What's left is exactly the effect body:
useEffect(() => {
const destroy = mount({ ...DEFAULTS });
return destroy;
}, []);
*(2) Element lookups* — mount() opens with document.querySelector(".trail-container") and quietly returns a no-op destroy if it doesn't find one — harmless as a guard, but the lookup itself assumes this component owns the whole document. Scope it to a root ref: pass the container element into mount (or have it query inside rootRef.current instead of document) rather than letting it search globally. This is more than a style objection during the StrictMode remount specifically — for an instant two .trail-container elements exist in the tree, and an unscoped querySelector can bind the trail to the one that's on its way out while the visible copy never gets its listener.
*(3) Cleanup* — mount()'s own destroy() already does what the rAF-driven components in this catalogue usually need adding by hand: it calls cancelAnimationFrame on the exact handle animationState holds, and it removes the same handlePointerMove reference addEventListener was given. Return that destroy unmodified from the effect — it is already the correct cleanup function, not a legacy shape to be translated into something more idiomatic.
One part of it is worth understanding rather than just trusting: createTrailImage() and removeOldImages() each schedule several setTimeout calls per spawned image — the middle-out stagger that opens the ten mask layers, the edge-out stagger that closes them, and the final removal fired after the out-animation finishes. None of those timer ids are captured, so destroy() has no way to cancel them individually. That is safe only because of the line right after it: trailContainer.innerHTML = "" detaches every node those pending timeouts still reference in one shot, so a timer that fires after unmount just sets a clipPath or an opacity on an element nothing renders anymore. Keep that wholesale clear exactly where it is in the returned cleanup — the correctness here comes from clearing the whole container at once, not from tracking every stray timer, and adding per-timeout bookkeeping would be solving a problem this component doesn't actually have.
The matchMedia("(pointer: coarse)").matches check that lowers the spawn threshold for touch runs once, synchronously, when mount() is called — it isn't a subscription, so it needs no listener removal in the cleanup. Don't add a matchMedia change handler where the original has none; a device switching pointer types mid-session is not a case this component ever handled.