SHARE
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.
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>