All components

24/7 Artists Image Trail

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A cursor-driven image trail across a full-viewport hero: as the mouse moves past a distance threshold, a new 175px image spawns at the cursor and slides toward it, revealed through 10 horizontal clip-path mask layers that open from the center outward with a staggered ease, then collapse and fade back to the center after a ~1s lifespan. Pure vanilla JS with CSS transitions and requestAnimationFrame (no GSAP); disabled below 1000px width.

How it's built

Categoryinteractive
Techvanilla JS
Complexitysection
Performance costmedium
Mobile-safedesktop-first

image-trail cursor mouse-trail clip-path reveal vanilla-js css-transitions editorial hover

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-container starts empty — JS creates every .trail-img element 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 via Math.hypot.
  • isInTrailContainer(x, y) — bounds check of the raw mouse position against trailContainer.getBoundingClientRect().
  • Mouse tracking: document.addEventListener("mousemove", e => { mousePos.x = e.clientX; mousePos.y = e.clientY; }).
rAF render loop (runs every frame while desktop)
  1. distance = Math.hypot(mousePos - lastMousePos) (distance traveled since the last spawn).
  2. 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).

  1. If distance > config.mouseThreshold and the raw mouse is inside the trail container → createTrailImage() and lastMousePos = { ...mousePos }.
  2. removeOldImages().
  3. requestAnimationFrame(render) again.
createTrailImage() — spawn + slide + striped reveal
  1. Create div.trail-img; pick images[currentImageIndex] then currentImageIndex = (currentImageIndex + 1) % 20.
  2. Positions are container-relative and centered on the point (offset by 87.5 = half of 175):
  3. startX/Y = interpolatedMousePos − containerRect.left/top − 87.5 (the lagged position — this is where the image appears),
  4. targetX/Y = mousePos − containerRect.left/top − 87.5 (the live cursor position — this is where it slides to).
  5. Set inline left/top to 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).

  1. Build 10 mask layers (i = 0…9). For each:
  2. startY = i * 10, endY = (i + 1) * 10 (each layer owns one 10%-tall horizontal strip).
  3. Inner div.image-layer with background-image: url(imgSrc) (all 10 layers use the SAME image; each strip just reveals its own slice because the parent clip crops it).
  4. 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.
  5. transition: clip-path 750ms cubic-bezier(0.87,0,0.13,1) (inDuration + easing), plus GPU hints transform: translateZ(0) and backface-visibility: hidden.
  6. Append the container to .trail-container, then inside a requestAnimationFrame callback (so initial styles commit first):
  7. set left/top to the target position (kicks off the 1s slide toward the cursor),
  8. for each layer i, after delay = Math.abs(i - 4.5) * config.staggerIn (via setTimeout), 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.

  1. Push { element, maskLayers, imageLayers, removeTime: Date.now() + config.imageLifespan } onto the trail queue.
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-set transition: clip-path 1000ms cubic-bezier(0.87,0,0.13,1) (outDuration), then after delay = (4.5 - Math.abs(i - 4.5)) * config.staggerOut (via setTimeout) 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-layer gets transition: opacity 1000ms cubic-bezier(0.87,0,0.13,1) and opacity: 0.25 — the image dims as its strips close.
  • Remove the element from the DOM after config.outDuration + 112 ms (setTimeout).
Desktop gate & lifecycle
  • The whole effect only runs when window.innerWidth > 1000. startAnimation() attaches the mousemove listener 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 calls startAnimation(), desktop→mobile calls stopAnimation() plus the mousemove cleanup.
  • Declaration order matters: wrap the entire effect in a single init function, and inside it declare ALL state (config, trail, currentImageIndex, mouse position objects, the isDesktop flag, the rAF id variable, the cleanUpMouseListener reference) before defining/wiring any listeners or calling startAnimation(). Nothing outside init may reference these variables — this prevents any use-before-declaration (TDZ) crash.
  • Bootstrapping (use this exact pattern, nothing else): define init fully 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 #101010 body, 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-motion branch, 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's overflow: hidden clips images spawned near the edges.