Image trail
This is a signature Awwwards move and the reason most implementations feel cheap comes down to two decisions, neither of them about the animation. The first is emit on distance, not on events: mousemove fires up to a hundred times a second, so spawning per event gives you a solid smear that ignores how fast the hand is actually moving. Gate it on ~90px of travel and a slow hand leaves a few frames while a fast one leaves a stream — the trail then describes the gesture. The second is pooling. The obvious build calls createElement per emission and removes the node on fade-out, which allocates, forces style recalculation, and hands the garbage collector work at precisely the moment the cursor is moving fastest. Ten elements that exist for the life of the page and are reused by index cost nothing and never stutter.
How to build
Script only
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
gsap core
Build time
1 hour. The tuning is longer than the code.
No-code?
No. There is no concept of a pool, an emission or a distance threshold in the Interactions panel.
Webflow structure
element · class · attribute<b>Section</b> <i>trail_stage</i> ← position: relative, overflow: hidden, min-height 100svh, grid place-items centre <b>Div</b> <i>trail_pool</i> <u>aria-hidden="true"</u> ← absolute inset 0, pointer-events: none, z-index 1 <b>Div</b> <i>trail_img</i> ← absolute, top 0 left 0, negative margins <b>Image</b> <u>draggable="false"</u> <b>Div</b> <i>trail_img</i> × 10 ← ten is plenty. Twelve is the ceiling. <b>Div</b> <i>trail_center</i> ← z-index 2, pointer-events: none <b>Heading H2</b> ← mix-blend-mode: difference
Styles to set in the Designer
trail_stage | <code>position: relative; overflow: hidden;</code> — the overflow is what stops a frame emitted near the edge from adding a horizontal scrollbar to the whole site. trail_img | <code>position: absolute; top: 0; left: 0;</code> plus a negative margin of half its width and height. Centre it with margins, never with <code>translate(-50%,-50%)</code> — GSAP owns the transform. trail_img | <code>opacity: 0; will-change: transform, opacity;</code> and a fixed <code>aspect-ratio</code>. All ten sit in the DOM from first paint doing nothing. trail_pool | <code>pointer-events: none</code> on the pool, not on each image — one rule, and the images can never eat a click meant for the content underneath. trail_center h2 | <code>mix-blend-mode: difference</code> so the headline stays readable no matter which image passes behind it. trail_stage (mobile) | Nothing to do — the script exits on any device without a fine pointer, so the section is simply a headline.
Building it with the Interactions panel
no code, free planWhat the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesEvery trail image | draggable | false | Native image drag will hijack the pointer the moment someone presses while moving.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { // A trail on a touch device is a trail that never appears. Leave early. if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return; if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const stage = document.querySelector(".trail_stage"); const imgs = gsap.utils.toArray(".trail_img"); const THRESHOLD = 92; // px of travel between frames let index = 0, z = 1, lastX = 0, lastY = 0, primed = false; function emit(x, y, angle) { const el = imgs[index++ % imgs.length]; // recycle by index z++; // Kill FIRST. A recycled element may still be fading out from its last // life, and two tweens on one node is how a trail starts flickering. gsap.killTweensOf(el); gsap.set(el, { x, y, zIndex: z, // newest always on top opacity: 1, scale: 0.72, rotate: gsap.utils.clamp(-16, 16, angle * 0.22) // lean into the gesture }); gsap.to(el, { scale: 1, duration: 0.5, ease: "expo.out" }); gsap.to(el, { opacity: 0, scale: 0.9, duration: 0.55, delay: 0.42, ease: "power2.in" }); } stage.addEventListener("mousemove", e => { const r = stage.getBoundingClientRect(); const x = e.clientX - r.left; // stage-relative, so the const y = e.clientY - r.top; // section can live anywhere if (!primed) { primed = true; lastX = x; lastY = y; return; } const dx = x - lastX, dy = y - lastY; // Distance, not events. This is what makes the trail describe the gesture // instead of describing your browser's event rate. if (Math.hypot(dx, dy) < THRESHOLD) return; lastX = x; lastY = y; emit(x, y, Math.atan2(dy, dx) * 180 / Math.PI); }); // Re-entering somewhere else must not emit a frame along the way. stage.addEventListener("mouseleave", () => primed = false); }); </script>
Gotchas that will cost you an hour
- Emit on distance, not on mousemove. The event fires far faster than anyone needs; spawning per event gives you a solid smear that looks identical whether the hand moved slowly or flew.
- Pool the elements. createElement per frame allocates and forces style recalculation, and the garbage collector then runs exactly when the pointer is moving fastest. Ten reused divs never stutter.
- killTweensOf before you reuse an element. Move fast enough and the pool wraps before an image has finished fading; without the kill you get two tweens on one node and a visible flicker.
- Increment zIndex on every emission. Without it the pool's DOM order decides the stacking, so an older frame can sit on top of a newer one and the trail appears to jump backwards.
- Reset the primed flag on mouseleave. Otherwise leaving one side and re-entering the other counts as one enormous movement and fires a frame in the middle of nowhere.
- Measure against the stage, not the viewport. Using clientX raw works only while the section happens to start at the top of the page. Subtract getBoundingClientRect() and it works anywhere.
- Ten images, not thirty. Beyond about twelve the screen is a collage rather than a trail, and every extra one is a composited layer you are paying for.
Push it further
- Use pointer velocity to set the scale as well as the rotation — a fast flick leaves bigger frames, and the trail gains weight.
- Alternate the images from a CMS list so the trail shows real work rather than the same ten pictures.
- Add a click that Flips the topmost frame into a full detail view — the Flip gallery's pattern, on a live trail.
Webflow Motion Lab — GSAP 3.15, all plugins free.
25
/ 30