SHARE
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:
Setting Up the Foundation in Elementor: The basic structure requires several key components:
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:
Customization and Responsiveness The animation can be customized in several ways:
GSAP Integration The GSAP (GreenSock Animation Platform) code provides the smooth animation functionality:
Advanced Features and Considerations When implementing the marquee animation, keep in mind:
Best Practices and Tips To ensure optimal performance:
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.
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. 🙂
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.
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>
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;
}
}
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.
* {
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;
}
}
<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>