Add the Custom Attributes on the text/div below to activate the text animation on any element on this template.
This script creates a mouse-following image trail effect using GSAP. When you move the cursor over a section with fc-trail-image="component", it dynamically fades and scales images in and out, leaving a trail. You can customize behavior using attributes like fc-trail-image-threshold, fc-trail-image-scale-from, and more—all within Webflow’s custom attribute panel.
<!-- GSAP Trail Image Effect -->
<style>
[trail-image=list] img {
opacity: 0;
position: absolute;
will-change: transform;
pointer-events: none;
max-width: none;
}
</style>
<script>
const MathUtils = {
lerp: (a, b, n) => (1 - n) * a + n * b,
distance: (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1)
};
const getMousePos = (e, container) => {
const rect = container.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
};
class Image {
constructor(el) {
this.DOM = { el: el };
this.defaultStyle = {
scale: 1,
x: 0,
y: 0,
opacity: 0
};
this.getRect();
this.initEvents();
}
initEvents() {
window.addEventListener('resize', () => this.resize());
}
resize() {
gsap.set(this.DOM.el, this.defaultStyle);
this.getRect();
}
getRect() {
this.rect = this.DOM.el.getBoundingClientRect();
}
isActive() {
return gsap.isTweening(this.DOM.el) || this.DOM.el.style.opacity != 0;
}
}
class ImageTrail {
constructor(list, mouseThreshold, opacityFrom, scaleFrom, opacityTo, scaleTo, mainDuration, mainEase, fadeOutDuration, fadeOutDelay, fadeOutEase, resetIndex, resetIndexDelay) {
this.DOM = { content: list };
this.images = [];
[...this.DOM.content.querySelectorAll('img')].forEach(img => this.images.push(new Image(img)));
this.imagesTotal = this.images.length;
this.imgPosition = 0;
this.zIndexVal = 1;
this.threshold = isNaN(mouseThreshold) ? 100 : mouseThreshold;
this.frameCount = 0;
this.resetIndex = resetIndex === null ? "false" : resetIndex;
this.resetIndexDelay = isNaN(resetIndexDelay) ? 200 : resetIndexDelay;
this.opacityFrom = isNaN(opacityFrom) ? 0.6 : opacityFrom;
this.scaleFrom = isNaN(scaleFrom) ? 0.8 : scaleFrom;
this.opacityTo = isNaN(opacityTo) ? 1 : opacityTo;
this.scaleTo = isNaN(scaleTo) ? 1 : scaleTo;
this.mainDuration = isNaN(mainDuration) ? 0.7 : mainDuration;
this.mainEase = mainEase === null ? 'power3' : mainEase;
this.fadeOutDuration = isNaN(fadeOutDuration) ? 1 : fadeOutDuration;
this.fadeOutDelay = isNaN(fadeOutDelay) ? 0.3 : fadeOutDelay;
this.fadeOutEase = fadeOutEase === null ? 'power3' : fadeOutEase;
this.mousePos = { x: 0, y: 0 };
this.lastMousePos = { x: 0, y: 0 };
this.cacheMousePos = { x: 0, y: 0 };
this.stopAnimationFrame = false;
}
render() {
const distance = MathUtils.distance(this.mousePos.x, this.mousePos.y, this.lastMousePos.x, this.lastMousePos.y);
this.cacheMousePos.x = MathUtils.lerp(this.cacheMousePos.x || this.mousePos.x, this.mousePos.x, 0.1);
this.cacheMousePos.y = MathUtils.lerp(this.cacheMousePos.y || this.mousePos.y, this.mousePos.y, 0.1);
if (distance > this.threshold) {
this.showNextImage();
++this.zIndexVal;
this.imgPosition = this.imgPosition < this.imagesTotal - 1 ? this.imgPosition + 1 : 0;
this.lastMousePos = this.mousePos;
}
let isIdle = true;
for (let img of this.images) {
if (img.isActive()) {
isIdle = false;
break;
}
}
if (isIdle) {
this.frameCount++;
if (this.resetIndex === "true" && this.frameCount >= this.resetIndexDelay) {
this.frameCount = 0;
this.imgPosition = 0;
}
if (this.zIndexVal !== 1) {
this.zIndexVal = 1;
}
}
if (!this.stopAnimationFrame) requestAnimationFrame(() => this.render());
}
showNextImage() {
const img = this.images[this.imgPosition];
gsap.killTweensOf(img.DOM.el);
const randomRotation = gsap.utils.random(-15, 15);
const xStart = (this.cacheMousePos.x / window.innerWidth) * 100;
const xEnd = (this.mousePos.x / window.innerWidth) * 100;
const yStart = this.cacheMousePos.y - img.rect.height / 2;
const yEnd = this.mousePos.y - img.rect.height / 2;
gsap.timeline()
.set(img.DOM.el, {
opacity: this.opacityFrom,
scale: this.scaleFrom,
zIndex: this.zIndexVal,
x: `${xStart}vw`,
y: yStart,
rotationZ: randomRotation
})
.to(img.DOM.el, {
ease: this.mainEase,
x: `${xEnd}vw`,
y: yEnd,
opacity: this.opacityTo,
scale: this.scaleTo,
rotationZ: randomRotation,
duration: this.mainDuration
})
.to(img.DOM.el, {
ease: this.fadeOutEase,
opacity: 0,
scale: this.scaleFrom,
duration: this.fadeOutDuration,
delay: this.fadeOutDelay,
onComplete: () => {
gsap.set(img.DOM.el, {
x: 0,
y: 0,
scale: 1,
opacity: 0,
zIndex: 1,
rotationZ: 0
});
}
});
}
}
document.addEventListener("DOMContentLoaded", () => {
requestAnimationFrame(() => {
const components = document.querySelectorAll('[trail-image=component]');
let imageTrails = [];
for (let i = 0; i < components.length; i++) {
const list = components[i].querySelector('[trail-image=list]');
const mouseThreshold = parseInt(components[i].getAttribute('trail-image-threshold'));
const opacityFrom = parseFloat(components[i].getAttribute('trail-image-opacity-from'));
const scaleFrom = parseFloat(components[i].getAttribute('trail-image-scale-from'));
const opacityTo = parseFloat(components[i].getAttribute('trail-image-opacity-to'));
const scaleTo = parseFloat(components[i].getAttribute('trail-image-scale-to'));
const mainDuration = parseFloat(components[i].getAttribute('trail-image-main-duration'));
const mainEase = components[i].getAttribute('trail-image-main-ease');
const fadeOutDuration = parseFloat(components[i].getAttribute('trail-image-fade-out-duration'));
const fadeOutDelay = parseFloat(components[i].getAttribute('trail-image-fade-out-delay'));
const fadeOutEase = components[i].getAttribute('trail-image-fade-out-ease');
const resetIndex = components[i].getAttribute('trail-image-reset-index');
const resetIndexDelay = parseInt(components[i].getAttribute('trail-image-reset-index-delay'));
components[i].addEventListener('mousemove', function (ev) {
if (imageTrails[i].resetIndex === "true" && imageTrails[i].frameCount > 0)
imageTrails[i].frameCount = 0;
imageTrails[i].mousePos = getMousePos(ev, components[i]);
});
components[i].addEventListener("mouseenter", function () {
imageTrails[i].stopAnimationFrame = false;
requestAnimationFrame(() => imageTrails[i].render());
if (imageTrails[i].resetIndex === "true") {
imageTrails[i].imgPosition = 0;
imageTrails[i].frameCount = 0;
}
});
components[i].addEventListener("mouseleave", function () {
imageTrails[i].stopAnimationFrame = true;
});
imageTrails.push(new ImageTrail(
list,
mouseThreshold,
opacityFrom,
scaleFrom,
opacityTo,
scaleTo,
mainDuration,
mainEase,
fadeOutDuration,
fadeOutDelay,
fadeOutEase,
resetIndex,
resetIndexDelay
));
}
});
});
</script>
trail-image="component"
*Required
Defines the entire interactive image trail wrapper
trail-image="list"
*Required
The wrapper that contains all the <img> elements to animate
trail-image-threshold
Optional
Minimum mouse movement (in px) before next image is triggered
trail-image-opacity-from
Optional
Starting opacity value for each image (e.g. 0.6)
trail-image-scale-from
Optional
Starting scale value for each image (e.g. 0.8)
trail-image-opacity-to
Optional
Final opacity value when image appears (e.g. 1)
trail-image-scale-to
Optional
Final scale value when image appears (e.g. 1)
trail-image-main-duration
Optional
Duration (in seconds) of the image entering animation
trail-image-main-ease
Optional
GSAP easing function (e.g. power3, sine.inOut)
trail-image-fade-out-duration
Optional
How long the image takes to fade out
trail-image-fade-out-delay
Optional
How long to wait before fading out
trail-image-fade-out-ease
Optional
GSAP easing function for the fade out
trail-image-reset-index
Optional
"true" resets image index when idle or on mouse enter
trail-image-reset-index-delay
Optional
Number of frames before reset happens if reset-index is "true"