Resources

GSAP Guide

Text animations

Add the Custom Attributes on the text/div below to activate the text animation on any element on this template.

On page load

Stagger letter

On page load

Stagger word

On page load

Stagger line

On scroll

Stagger letter

On scroll

Stagger word

On scroll

Stagger line

Cursor Image Trail (Home hero)

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>

Attributes

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"