GSAP, ELEMENTOR HOVER ANIMATION, TUTORIAL

How to create an Infinite Marquee with GSAP in Elementor Pro

SHARE

Marquee Examples built in Elementor with GSAP

Here are some of the important information of the video:

Elementor Setup

Add a Container Widget :

Class: marquee-container

Add Another Container Inside:

Class: marquee-content

Direction: Row Horizontal

Align-Items: Center

Add Inside an Icon Box Widget:

Class: marquee-item

Title-Line Height: 0

Some Important Additions:

Don’t use Clamp for any sizes when creating the marquee. Use Pixel or REM. No % or vw. It will break the animation. The tricky part is always the calculation of the width of all the items, especially when you have different widgets like image and Text widget. With GSAP Match Media every breakpoint calculates its own sizes.

When you are using ONLY image widget then the CSS is a bit different. In case you are interested let me know then i can make a seperate video for it or you find out yourself how it goes. 🙂

Two Marquees on one page as it is in my demo:

In general i built this marquee as a one Marquee on the page. So the code is optimzed for having one marquee. Nevertheless i provide you below the additional code for your second marquee. If it would be a general requirement to have two marquees on one page the code should be optimized, but as said this was not in focus of this demo. I just thought at one point that it looks cool having two marquees.

For the second marquee you just need to change the classes in the widget to:

Marquee Container: CSS Class –> marquee-container2

Marquee Content Container: CSS Class –> marquee-content2

Icon-Box Widget: CSS Class –> marquee-item2

Then paste in the CSS and GSAP Code which you find under the Titel “Second Marquee” below.

GSAP Code

Add the GSAP Code into your HTML Widget

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>

<script>

function horizontalLoop(items, config) {
  items = gsap.utils.toArray(items);
  config = config || {};
  let tl = gsap.timeline({
      repeat: config.repeat,
      paused: config.paused,
      defaults: { ease: "none" },
      onReverseComplete: () => tl.totalTime(tl.rawTime() + tl.duration() * 100),
    }),
    length = items.length,
    startX = items[0].offsetLeft,
    times = [],
    widths = [],
    xPercents = [],
    curIndex = 0,
    pixelsPerSecond = (config.speed || 1) * 100,
    snap = config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1), // some browsers shift by a pixel to accommodate flex layouts, so for example if width is 20% the first element's width might be 242px, and the next 243px, alternating back and forth. So we snap to 5 percentage points to make things look more natural
    totalWidth,
    curX,
    distanceToStart,
    distanceToLoop,
    item,
    i;
  gsap.set(items, {
    // convert "x" to "xPercent" to make things responsive, and populate the widths/xPercents Arrays to make lookups faster.
    xPercent: (i, el) => {
      let w = (widths[i] = parseFloat(gsap.getProperty(el, "width", "px")));
      xPercents[i] = snap(
        (parseFloat(gsap.getProperty(el, "x", "px")) / w) * 100 +
          gsap.getProperty(el, "xPercent")
      );
      return xPercents[i];
    },
  });
  gsap.set(items, { x: 0 });
  totalWidth =
    items[length - 1].offsetLeft +
    (xPercents[length - 1] / 100) * widths[length - 1] -
    startX +
    items[length - 1].offsetWidth *
      gsap.getProperty(items[length - 1], "scaleX") +
    (parseFloat(config.paddingRight) || 0);
  for (i = 0; i < length; i++) {
    item = items[i];
    curX = (xPercents[i] / 100) * widths[i];
    distanceToStart = item.offsetLeft + curX - startX;
    distanceToLoop =
      distanceToStart + widths[i] * gsap.getProperty(item, "scaleX");
    tl.to(
      item,
      {
        xPercent: snap(((curX - distanceToLoop) / widths[i]) * 100),
        duration: distanceToLoop / pixelsPerSecond,
      },
      0
    )
      .fromTo(
        item,
        {
          xPercent: snap(
            ((curX - distanceToLoop + totalWidth) / widths[i]) * 100
          ),
        },
        {
          xPercent: xPercents[i],
          duration:
            (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond,
          immediateRender: false,
        },
        distanceToLoop / pixelsPerSecond
      )
      .add("label" + i, distanceToStart / pixelsPerSecond);
    times[i] = distanceToStart / pixelsPerSecond;
  }
  function toIndex(index, vars) {
    vars = vars || {};
    Math.abs(index - curIndex) > length / 2 &&
      (index += index > curIndex ? -length : length); // always go in the shortest direction
    let newIndex = gsap.utils.wrap(0, length, index),
      time = times[newIndex];
    if (time > tl.time() !== index > curIndex) {
      // if we're wrapping the timeline's playhead, make the proper adjustments
      vars.modifiers = { time: gsap.utils.wrap(0, tl.duration()) };
      time += tl.duration() * (index > curIndex ? 1 : -1);
    }
    curIndex = newIndex;
    vars.overwrite = true;
    return tl.tweenTo(time, vars);
  }
  tl.next = (vars) => toIndex(curIndex + 1, vars);
  tl.previous = (vars) => toIndex(curIndex - 1, vars);
  tl.current = () => curIndex;
  tl.toIndex = (index, vars) => toIndex(index, vars);
  tl.times = times;
  tl.progress(1, true).progress(0, true); // pre-render for performance
  if (config.reversed) {
    tl.vars.onReverseComplete();
    tl.reverse();
  }
  return tl;
}


 let mm = gsap.matchMedia();

mm.add("(min-width: 1025px) and (prefers-reduced-motion: no-preference)", () => restartMarquee(1, 0)); //change here the speed and padding right for desktop//
mm.add("(min-width: 768px) and (max-width: 1024px) and (prefers-reduced-motion: no-preference)", () => restartMarquee(1, 0));//change here the speed and padding right for tablet//
mm.add("(max-width: 767px) and (prefers-reduced-motion: no-preference)", () => restartMarquee(0.8, 0)); //change here the speed and padding right for mobile//

function restartMarquee(speed, paddingRight) {
  // 🎯 Prüfe, ob der Nutzer reduzierte Bewegung bevorzugt
  const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (prefersReducedMotion) {
    console.log("🛑 prefers-reduced-motion erkannt! Marquee wird NICHT gestartet.");
    if (window.loop) {
      window.loop.kill();
      window.loop = null;
    }

    // Alle Animationen der Marquee-Elemente stoppen und statisch setzen
    gsap.killTweensOf(".marquee-item");
    gsap.set(".marquee-item", { clearProps: "all" });

    document.querySelectorAll(".marquee-item").forEach(item => {
      item.style.transform = "translate(0, 0)";
      item.style.animation = "none";
    });

    return; // Beende die Funktion hier, damit keine neue Animation gestartet wird
  }

  if (window.loop) {
    window.loop.kill(); // Stoppe die alte Animation
    window.loop = null;
  }

  let direction = 1;
  const marqueeContainer = document.querySelector(".marquee-container");

  if (marqueeContainer) {
    marqueeContainer.addEventListener("mouseenter", () => {
      if (window.loop) window.loop.pause();
    });

    marqueeContainer.addEventListener("mouseleave", () => {
      if (window.loop) {
        direction *= -1;
        gsap.to(window.loop, { timeScale: direction, overwrite: true });
        window.loop.play();
      }
    });

    marqueeContainer.addEventListener("touchstart", (e) => {
      e.preventDefault();
      if (window.loop) window.loop.pause();
    });

    marqueeContainer.addEventListener("touchend", () => {
      if (window.loop) {
        direction *= -1;
        gsap.to(window.loop, { timeScale: direction, overwrite: true });
        window.loop.play();
      }
    });
  }

  gsap.set(".marquee-item", { clearProps: "all" });

  setTimeout(() => {
    window.loop = horizontalLoop(".marquee-item", {
      speed: speed,  
      repeat: -1,
      paused: false,
      paddingRight: paddingRight,
    });
  }, 100);
}


let resizeTimeout;
window.addEventListener("resize", () => {
  clearTimeout(resizeTimeout);
  resizeTimeout = setTimeout(() => {
    mm.revert(); // Reset MatchMedia animations
    restartMarqueeForCurrentMedia(); // Restart marquee with updated sizes
  }, 200);
});

function restartMarqueeForCurrentMedia() {
  let speed = 1;
  let paddingRight = 0;

  if (window.matchMedia("(min-width: 1024px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 1;
    paddingRight = 0;
  } else if (window.matchMedia("(min-width: 768px) and (max-width: 1023px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 1;
    paddingRight = 0;
  } else if (window.matchMedia("(max-width: 767px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 0.8;
    paddingRight = 0;
  }

  restartMarquee(speed, paddingRight);
}



</script>

CSS Code

Add the Code into your Custom CSS or in the Custom CSS Widget

* {
  box-sizing: border-box;
}

.marquee-container {
  width: 100% !important;
  overflow: hidden !important;
  white-space: nowrap !important;
  position: relative !important;
}

.marquee-content {
  display: flex !important;
}

.marquee-item {
  flex: 0 0 auto !important;
  padding: 0 0 !important;
  max-width: none !important;
  margin: 0 !important;
  justify-content: center !important;
}

/* Desktop and General Settings, will be overwritten for Tablet and Mobile with Media Queries below */


/* Spacing between icon and title (horizontal) */
.marquee-item > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 40px !important;
  line-height: 0 !important;
}


/* Tablet Settings */

@media (max-width: 1024px) {
    
    /* 4️⃣ 🛠 Fix for mobile: Removes unnecessary space caused by the Content-Box and aligns icon and heading */
  .marquee-item .elementor-widget-container .elementor-icon-box-wrapper {
    text-align: center !important;
}
 
 /* Spacing between icon and title (horizontal) */
 .marquee-item > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 30px !important;
  line-height: 0 !important;
}

}


/* Mobile Settings */

@media (max-width: 767px) {
    
/* 4️⃣ 🛠 Fix for mobile: Removes unnecessary space caused by the Content-Box and aligns icon and heading */
 .marquee-item .elementor-widget-container .elementor-icon-box-wrapper .elementor-icon-box-content {
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
  }
  
  /* Spacing between icon and title (horizontal) */
 .marquee-item > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 30px !important;
  line-height: 0 !important;
}

}

Second Marquee

Here you find the Codes for the additional Marquee. It is already set with reversed: true , so that the marquee starts initially in the reversed direction as the first marquee.

Second Marquee CSS Code for Custom CSS

* {
  box-sizing: border-box;
}

.marquee-container2 {
  width: 100% !important;
  overflow: hidden !important;
  white-space: nowrap !important;
  position: relative !important;
}

.marquee-content2 {
  display: flex !important;
}

.marquee-item2 {
  flex: 0 0 auto !important;
  padding: 0 0 !important;
  max-width: none !important;
  margin: 0 !important;
  justify-content: center !important;
}

/* Desktop and General Settings, will be overwritten for Tablet and Mobile with Media Queries below */


/* Spacing between icon and title (horizontal) */
.marquee-item2 > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 40px !important;
  line-height: 0 !important;
}


/* Tablet Settings */

@media (max-width: 1024px) {
    
    /* 4️⃣ 🛠 Fix for mobile: Removes unnecessary space caused by the Content-Box and aligns icon and heading */
  .marquee-item2 .elementor-widget-container .elementor-icon-box-wrapper {
    text-align: center !important;
}
 
 /* Spacing between icon and title (horizontal) */
 .marquee-item2 > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 30px !important;
  line-height: 0 !important;
}

}


/* Mobile Settings */

@media (max-width: 767px) {
    
/* 4️⃣ 🛠 Fix for mobile: Removes unnecessary space caused by the Content-Box and aligns icon and heading */
 .marquee-item2 .elementor-widget-container .elementor-icon-box-wrapper .elementor-icon-box-content {
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
  }
  
  /* Spacing between icon and title (horizontal) */
 .marquee-item2 > .elementor-widget-container > .elementor-icon-box-wrapper > .elementor-icon-box-icon {
   margin-right: 30px !important;
  line-height: 0 !important;
}

}

Second Marquee GSAP Code for HTML Widget

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.7/dist/gsap.min.js"></script>

<script>

function horizontalLoop(items, config) {
  items = gsap.utils.toArray(items);
  config = config || {};
  let tl = gsap.timeline({
      repeat: config.repeat,
      paused: config.paused,
      defaults: { ease: "none" },
      onReverseComplete: () => tl.totalTime(tl.rawTime() + tl.duration() * 100),
    }),
    length = items.length,
    startX = items[0].offsetLeft,
    times = [],
    widths = [],
    xPercents = [],
    curIndex = 0,
    pixelsPerSecond = (config.speed || 1) * 100,
    snap = config.snap === false ? (v) => v : gsap.utils.snap(config.snap || 1), // some browsers shift by a pixel to accommodate flex layouts, so for example if width is 20% the first element's width might be 242px, and the next 243px, alternating back and forth. So we snap to 5 percentage points to make things look more natural
    totalWidth,
    curX,
    distanceToStart,
    distanceToLoop,
    item,
    i;
  gsap.set(items, {
    // convert "x" to "xPercent" to make things responsive, and populate the widths/xPercents Arrays to make lookups faster.
    xPercent: (i, el) => {
      let w = (widths[i] = parseFloat(gsap.getProperty(el, "width", "px")));
      xPercents[i] = snap(
        (parseFloat(gsap.getProperty(el, "x", "px")) / w) * 100 +
          gsap.getProperty(el, "xPercent")
      );
      return xPercents[i];
    },
  });
  gsap.set(items, { x: 0 });
  totalWidth =
    items[length - 1].offsetLeft +
    (xPercents[length - 1] / 100) * widths[length - 1] -
    startX +
    items[length - 1].offsetWidth *
      gsap.getProperty(items[length - 1], "scaleX") +
    (parseFloat(config.paddingRight) || 0);
  for (i = 0; i < length; i++) {
    item = items[i];
    curX = (xPercents[i] / 100) * widths[i];
    distanceToStart = item.offsetLeft + curX - startX;
    distanceToLoop =
      distanceToStart + widths[i] * gsap.getProperty(item, "scaleX");
    tl.to(
      item,
      {
        xPercent: snap(((curX - distanceToLoop) / widths[i]) * 100),
        duration: distanceToLoop / pixelsPerSecond,
      },
      0
    )
      .fromTo(
        item,
        {
          xPercent: snap(
            ((curX - distanceToLoop + totalWidth) / widths[i]) * 100
          ),
        },
        {
          xPercent: xPercents[i],
          duration:
            (curX - distanceToLoop + totalWidth - curX) / pixelsPerSecond,
          immediateRender: false,
        },
        distanceToLoop / pixelsPerSecond
      )
      .add("label" + i, distanceToStart / pixelsPerSecond);
    times[i] = distanceToStart / pixelsPerSecond;
  }
  function toIndex(index, vars) {
    vars = vars || {};
    Math.abs(index - curIndex) > length / 2 &&
      (index += index > curIndex ? -length : length); // always go in the shortest direction
    let newIndex = gsap.utils.wrap(0, length, index),
      time = times[newIndex];
    if (time > tl.time() !== index > curIndex) {
      // if we're wrapping the timeline's playhead, make the proper adjustments
      vars.modifiers = { time: gsap.utils.wrap(0, tl.duration()) };
      time += tl.duration() * (index > curIndex ? 1 : -1);
    }
    curIndex = newIndex;
    vars.overwrite = true;
    return tl.tweenTo(time, vars);
  }
  tl.next = (vars) => toIndex(curIndex + 1, vars);
  tl.previous = (vars) => toIndex(curIndex - 1, vars);
  tl.current = () => curIndex;
  tl.toIndex = (index, vars) => toIndex(index, vars);
  tl.times = times;
  tl.progress(1, true).progress(0, true); // pre-render for performance
  if (config.reversed) {
    tl.vars.onReverseComplete();
    tl.reverse();
  }
  return tl;
}


 let mm2 = gsap.matchMedia();

mm2.add("(min-width: 1024px) and (prefers-reduced-motion: no-preference)", () => restartMarquee2(1, 0));
mm2.add("(min-width: 768px) and (max-width: 1023px) and (prefers-reduced-motion: no-preference)", () => restartMarquee2(1, 0));
mm2.add("(max-width: 767px) and (prefers-reduced-motion: no-preference)", () => restartMarquee2(0.8, 0));

function restartMarquee2(speed2, paddingRight2) {
  // 🎯 Prüfe, ob der Nutzer reduzierte Bewegung bevorzugt
  const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (prefersReducedMotion) {
    console.log("🛑 prefers-reduced-motion erkannt! Marquee wird NICHT gestartet.");
    if (window.loop2) {
      window.loop2.kill();
      window.loop2 = null;
    }

    // Alle Animationen der Marquee-Elemente stoppen und statisch setzen
    gsap.killTweensOf(".marquee-item2");
    gsap.set(".marquee-item2", { clearProps: "all" });

    document.querySelectorAll(".marquee-item2").forEach(item => {
      item.style.transform = "translate(0, 0)";
      item.style.animation = "none";
    });

    return; // Beende die Funktion hier, damit keine neue Animation gestartet wird
  }

  if (window.loop2) {
    window.loop2.kill(); // Stoppe die alte Animation
    window.loop2 = null;
  }

  let direction = 1;
  const marqueeContainer2 = document.querySelector(".marquee-container2");

  if (marqueeContainer2) {
    marqueeContainer2.addEventListener("mouseenter", () => {
      if (window.loop2) window.loop2.pause();
    });

    marqueeContainer2.addEventListener("mouseleave", () => {
      if (window.loop2) {
        direction *= -1;
        gsap.to(window.loop2, { timeScale: direction, overwrite: true });
        window.loop2.play();
      }
    });

    marqueeContainer2.addEventListener("touchstart", (e) => {
      e.preventDefault();
      if (window.loop2) window.loop2.pause();
    });

    marqueeContainer2.addEventListener("touchend", () => {
      if (window.loop2) {
        direction *= -1;
        gsap.to(window.loop2, { timeScale: direction, overwrite: true });
        window.loop2.play();
      }
    });
  }



  gsap.set(".marquee-item2", { clearProps: "all" });

  setTimeout(() => {
    window.loop2 = horizontalLoop(".marquee-item2", {
      speed: speed2,
      repeat: -1,
      paused: false,
      reversed: true,
      paddingRight: paddingRight2,
    });
  }, 100);
}


let resizeTimeout2;
window.addEventListener("resize", () => {
  clearTimeout(resizeTimeout2);
  resizeTimeout2 = setTimeout(() => {
    mm2.revert(); // Reset MatchMedia animations
    restartMarqueeForCurrentMedia2(); // Restart marquee with updated sizes
  }, 200);
});

function restartMarqueeForCurrentMedia2() {
  let speed2 = 1;
  let paddingRight2 = 0;

  if (window.matchMedia("(min-width: 1024px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 1;
    paddingRight = 0;
  } else if (window.matchMedia("(min-width: 768px) and (max-width: 1023px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 1;
    paddingRight = 0;
  } else if (window.matchMedia("(max-width: 767px) and (prefers-reduced-motion: no-preference)").matches) {
    speed = 0.8;
    paddingRight = 0;
  }

  restartMarquee2(speed2, paddingRight2);
}



</script>

GDPR Cookie Consent with Real Cookie Banner