GSAP, ELEMENTOR HOVER ANIMATION, TUTORIAL

How to create an Infinite Marquee with GSAP in Elementor Pro

SHARE

Marquee Examples built in Elementor with GSAP

Infinite Elementor Marquee Animation with GSAP

A Step-by-Step Guide including Hover Animation and Responsiveness

Introduction In today’s digital landscape, engaging website animations can significantly enhance user experience. One particularly eye-catching effect is the infinite horizontal marquee animation, which can be created using Elementor Pro, CSS, and GSAP. This tutorial explores how to build a smooth, interactive, and fully responsive marquee animation suitable for logos, text, or any scrolling content.

Key Features of the Marquee Animation The marquee animation offers sophisticated functionality across all devices:

  • Desktop: Smooth horizontal scrolling with hover interaction that pauses and reverses direction
  • Mobile/Tablet: Touch-responsive with direction change on interaction
  • Customizable speeds for different device types
  • Fully responsive design that maintains smooth animation across all screen sizes

Setting Up the Foundation in Elementor: The basic structure requires several key components:

  1. Main Container Setup
  • Create a marquee container with full width
  • Set overflow to hidden
  • Apply the ‘marquee-container’ CSS class
  • Configure margin and padding to zero
  1. Content Container Configuration
  • Set full width with horizontal row direction
  • Enable center alignment
  • Disable wrapping
  • Add the ‘marquee-content’ CSS class

Building the Marquee Elements The marquee utilizes the Icon Box widget, though it’s compatible with both image and heading widgets.

Key configuration steps include:

  • Removing unnecessary text elements
  • Setting title tags to div
  • Configuring icon position and spacing
  • Adjusting responsive sizes for different devices
  • Adding the ‘marquee-item’ CSS class

Customization and Responsiveness The animation can be customized in several ways:

  • Speed adjustment for desktop, tablet, and mobile devices
  • Direction control (left-to-right or right-to-left)
  • Breakpoint management to match Elementor settings
  • Color schemes and spacing modifications

GSAP Integration The GSAP (GreenSock Animation Platform) code provides the smooth animation functionality:

  • Customizable speed values for each device type
  • Breakpoint management for responsive behavior
  • Direction control through simple parameter adjustment
  • Interactive pause and reverse functions

Advanced Features and Considerations When implementing the marquee animation, keep in mind:

  • Multiple marquees require different variable sets
  • Breakpoint values must match Elementor settings
  • Speed can be adjusted through simple numerical values
  • Touch interaction requires specific mobile configuration

Best Practices and Tips To ensure optimal performance:

  • Duplicate items to cover viewport width plus extra items
  • Maintain consistent spacing between elements
  • Adjust line height for proper icon alignment
  • Test thoroughly across different devices and screen sizes

Conclusion This marquee animation solution provides a professional, interactive element that enhances website engagement while maintaining full responsiveness. The combination of Elementor Pro’s flexibility with GSAP’s animation capabilities creates a smooth, customizable scrolling effect suitable for various applications.

The best part about this implementation is its versatility – whether you’re showcasing client logos, featuring important text, or displaying product information, the marquee animation can be adapted to suit your specific needs while maintaining professional polish across all devices.

By following these steps and customizing the parameters to match your website’s requirements, you can create an engaging, interactive marquee that enhances your site’s visual appeal and user experience.

Additions to my GSAP Marquee Tutorial:

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 Animation

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