This script creates an infinite marquee that scrolls continuously in a loop. The marquee can move horizontally or vertically, and the direction is determined automatically by the wrapper’s flex direction.
flex-direction: row, the marquee scrolls horizontally. flex-direction: column, it scrolls vertically.Because of this, you can change the marquee direction per breakpoint simply by changing flex-direction in CSS. For example, the marquee can scroll horizontally on desktop and vertically on mobile just by adjusting flex styles.
Because the scroll direction is tied to flex direction, you can change the marquee’s direction per breakpoint simply by updating flex-direction in the editor. This makes it easy to have a horizontal marquee on desktop and a vertical one on mobile.
The marquee can be built using almost any widget or element inside the editor. As long as items are placed inside the marquee wrapper, the script handles layout, duplication, and animation automatically. This keeps setup flexible and editor-friendly.
The script duplicates marquee items as needed to ensure there are always enough elements to create a seamless, never-ending scroll with no visible gaps. Speed and reverse direction can be controlled using attributes.
Copy & paste the scripts before the </body> tag of your project. If you added them before for another setup, skip this step.
<!-- GSAP -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>Right-click in Elementor, choose “Paste from another site,” and while the popup is open, press cmd/ctrl + v to insert the layout.
Place the code in an HTML widget or add it through Elementor → Custom Code (before the closing </body> tag) either globally or only on selected pages.
Paste the code through the page or site settings, or add it via Elementor → Custom Code (before </body>) for broader use.
Paste the script through Elementor → Custom Code (set to load after </body>) for site-wide or page-specific loading.
const marqueeAttr = {
wrapper: "ou-marquee-wrapper",
item: "ou-marquee-item",
speed: "ou-marquee-speed",
reverse: "ou-marquee-reverse",
intersection: "ou-marquee-intersection",
duplicate: "ou-marquee-duplicate",
track: "ou-marquee-track",
clone: "ou-marquee-clone"
};
const marqueeLimits = {
maxCount: 20
};
const marqueeInstances = new WeakMap();
function ouMarqueeNumAttr(el, name, fallback, min) {
const raw = el.getAttribute(name);
if (raw == null || raw.trim() === "") return fallback;
const v = parseFloat(raw);
if (!isFinite(v)) return fallback;
return typeof min === "number" ? Math.max(min, v) : v;
}
function ouMarqueeIntAttr(el, name, fallback, min, max) {
const raw = el.getAttribute(name);
if (raw == null || raw.trim() === "") return fallback;
const v = parseInt(raw, 10);
if (!isFinite(v)) return fallback;
if (typeof min === "number" && v < min) return min;
if (typeof max === "number" && v > max) return max;
return v;
}
function ouMarqueeBoolValueAttr(el, name, fallback = false) {
const v = el.getAttribute(name);
if (v == null) return fallback;
if (v === "true") return true;
if (v === "false") return false;
return fallback;
}
function ouMarqueePx(val) {
const n = parseFloat(val);
return isFinite(n) ? n : 0;
}
function ouMarqueeAxisFromFlex(wrapper) {
const fd = getComputedStyle(wrapper).flexDirection || "row";
return fd.indexOf("column") === 0 ? "vertical" : "horizontal";
}
function ouMarqueeApplyVisibility(inst) {
if (!inst.tl) return;
if (!inst.intersectionEnabled) return;
inst.isInView ? inst.tl.play() : inst.tl.pause();
}
function ouMarqueeSetupIntersection(inst) {
if (inst.io) {
inst.io.disconnect();
inst.io = null;
}
inst.intersectionEnabled = ouMarqueeBoolValueAttr(
inst.wrapper,
marqueeAttr.intersection,
true
);
if (!inst.intersectionEnabled) return;
if (!("IntersectionObserver" in window)) return;
inst.io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.target !== inst.wrapper) continue;
inst.isInView = !!entry.isIntersecting;
ouMarqueeApplyVisibility(inst);
}
},
{ threshold: 0.1, rootMargin: "0px" }
);
inst.io.observe(inst.wrapper);
}
function ouMarqueeCleanupClones(wrapper) {
wrapper.querySelectorAll("[" + marqueeAttr.clone + "]").forEach((n) => n.remove());
}
function ouMarqueeBuild(inst, preserve = true) {
const { wrapper } = inst;
if (!window.gsap) return;
const prevProgress = inst.tl ? inst.tl.progress() : 0;
const prevScale = inst.tl ? inst.tl.timeScale() : 1;
if (inst.tl) {
inst.tl.kill();
inst.tl = null;
}
wrapper.innerHTML = inst.originalHTML;
ouMarqueeCleanupClones(wrapper);
const items = Array.from(wrapper.querySelectorAll("[" + marqueeAttr.item + "]"));
if (!items.length) return;
const axis = ouMarqueeAxisFromFlex(wrapper);
inst.axis = axis;
const cs = getComputedStyle(wrapper);
wrapper.style.overflowX = "hidden";
wrapper.style.overflowY = "hidden";
const gapPx =
axis === "vertical"
? ouMarqueePx(cs.rowGap || cs.gap || "0px")
: ouMarqueePx(cs.columnGap || cs.gap || "0px");
const track = document.createElement("div");
track.setAttribute(marqueeAttr.track, "");
track.style.display = "flex";
track.style.flexWrap = "nowrap";
track.style.flexDirection = axis === "vertical" ? "column" : "row";
track.style.gap = cs.gap || "0px";
track.style.alignItems = cs.alignItems || "stretch";
track.style.willChange = "transform";
items.forEach((item) => {
item.style.flex = "0 0 auto";
track.appendChild(item);
});
wrapper.appendChild(track);
const viewSize =
axis === "vertical"
? wrapper.getBoundingClientRect().height
: wrapper.getBoundingClientRect().width;
const baseSize = axis === "vertical" ? track.scrollHeight : track.scrollWidth;
if (!baseSize || !viewSize) return;
const manualTotalCopies = ouMarqueeIntAttr(
wrapper,
marqueeAttr.duplicate,
0,
2,
marqueeLimits.maxCount
);
const autoCopies = Math.max(2, Math.ceil((viewSize * 2) / baseSize));
const cappedAutoCopies = Math.min(autoCopies, marqueeLimits.maxCount);
const totalCopies = Math.max(
2,
Math.min(
manualTotalCopies >= 2 ? manualTotalCopies : cappedAutoCopies,
marqueeLimits.maxCount
)
);
const originals = Array.from(track.children);
for (let i = 1; i < totalCopies; i++) {
originals.forEach((el) => {
const c = el.cloneNode(true);
c.setAttribute("aria-hidden", "true");
c.setAttribute(marqueeAttr.clone, "");
track.appendChild(c);
});
}
const cycleDistance = baseSize + gapPx;
const speedMultiplier = ouMarqueeNumAttr(wrapper, marqueeAttr.speed, 1, 0.01);
const pxPerSecond = 100 * speedMultiplier;
const duration = cycleDistance / pxPerSecond;
const reverse = wrapper.getAttribute(marqueeAttr.reverse) === "true";
const prop = axis === "vertical" ? "y" : "x";
const fromVal = reverse ? -cycleDistance : 0;
const toVal = reverse ? 0 : -cycleDistance;
const fromObj = {};
const toObj = { duration, ease: "none", repeat: -1 };
fromObj[prop] = fromVal;
toObj[prop] = toVal;
gsap.set(track, fromObj);
inst.tl = gsap.fromTo(track, fromObj, toObj);
if (preserve) {
inst.tl.progress(prevProgress);
inst.tl.timeScale(prevScale);
inst.tl.play();
}
ouMarqueeApplyVisibility(inst);
}
function ouMarqueeCreateInstance(wrapper) {
if (marqueeInstances.has(wrapper)) return;
const inst = {
wrapper,
originalHTML: wrapper.innerHTML,
tl: null,
ro: null,
io: null,
intersectionEnabled: true,
isInView: true,
_onWinResize: null
};
marqueeInstances.set(wrapper, inst);
ouMarqueeSetupIntersection(inst);
ouMarqueeBuild(inst, false);
if (typeof ResizeObserver === "function") {
inst.ro = new ResizeObserver(() => ouMarqueeBuild(inst, true));
inst.ro.observe(wrapper);
}
inst._onWinResize = () => ouMarqueeBuild(inst, true);
window.addEventListener("resize", inst._onWinResize, { passive: true });
}
const marquee = {
init(selector) {
if (document.body && document.body.classList.contains("elementor-editor-active")) return this;
if (window.elementorFrontend && window.elementorFrontend.isEditMode && window.elementorFrontend.isEditMode()) return this;
if (!window.gsap) return this;
const sel = selector || "[" + marqueeAttr.wrapper + "]";
document.querySelectorAll(sel).forEach(ouMarqueeCreateInstance);
return this;
},
get(el) {
return marqueeInstances.get(el) || null;
},
getAll(selector) {
const sel = selector || "[" + marqueeAttr.wrapper + "]";
return Array.from(document.querySelectorAll(sel))
.map((el) => marquee.get(el))
.filter(Boolean);
},
destroy(el) {
const inst = marqueeInstances.get(el);
if (!inst) return this;
inst.tl && inst.tl.kill();
inst.ro && inst.ro.disconnect();
inst.io && inst.io.disconnect();
inst._onWinResize && window.removeEventListener("resize", inst._onWinResize);
el.innerHTML = inst.originalHTML;
marqueeInstances.delete(el);
return this;
},
destroyAll(selector) {
const sel = selector || "[" + marqueeAttr.wrapper + "]";
document.querySelectorAll(sel).forEach((el) => this.destroy(el));
return this;
}
};
function initMarqueeScrollDirection(selector) {
marquee.init(selector);
}
document.addEventListener("DOMContentLoaded", () => {
initMarqueeScrollDirection();
});Place the PHP snippet inside your theme’s functions.php or using any code snippet to enable logic.
First, add an wrapper that will act as the marquee container. On this element, add the attribute ou-marquee-wrapper. This tells the script, “this is the marquee.”
Inside that wrapper, place whatever content you want to scroll. It can be text, images, icons, buttons, cards, or even mixed content. For each item that should scroll, add the attribute ou-marquee-item. There are no restrictions on what the item is, as long as it lives inside the wrapper.
The marquee does not use an attribute to decide whether it scrolls horizontally or vertically. Instead, it reads the wrapper’s flex direction and uses that to determine the scroll axis.
flex-direction: row → horizontal marqueeflex-direction: column → vertical marqueeBecause of this, you can change the marquee direction per breakpoint simply by changing flex-direction in your editor or CSS. For example, you might keep the marquee horizontal on desktop and switch it to vertical on mobile.
If you want the marquee to move in the opposite direction, add the ou-marquee-reverse attribute to the wrapper.
true to reverse the directionfalse for the default directionThis works for both horizontal and vertical marquees and does not affect speed or spacing.
To control how fast the marquee moves, add the ou-marquee-speed attribute to the marquee wrapper (the same element that has ou-marquee-wrapper). The value is just a number, and you can treat it like a simple speed knob.
Here’s the simple rule:
1 = normal (default)1 = slower (example: 0.7)1 = faster (example: 1.5 or 2)What’s happening behind the scenes is also pretty straightforward. The script has a built-in “base speed” it uses as a starting point, and then your number scales it up or down. The base speed in this code is 100 pixels per second, so your value works like a multiplier on top of that.
For example:
ou-marquee-speed="1" moves at about 100px per secondou-marquee-speed="0.5" moves at about 50px per secondou-marquee-speed="2" moves at about 200px per secondThe script also measures how long your marquee content is. If your items take up more space, the marquee has more distance to travel to complete a full loop, so one full cycle naturally takes longer. The nice part is: it still feels consistent, because the movement is based on pixels-per-second, not “one loop every X seconds no matter what.”
My practical advice: start at 1, then adjust in small steps like 0.8, 1.2, 1.5 until it feels right.
By default, the marquee automatically pauses when it leaves the viewport and resumes when it becomes visible again. This behavior is controlled using the ou-marquee-intersection attribute, which should also be added to the wrapper.
true enables automatic pausing (default)false keeps the marquee running even when it is off-screenThis is mainly a performance feature and is recommended.
The script automatically duplicates marquee items to make sure there are always enough elements to create a seamless, infinite loop. In most cases, you don’t need to think about this at all.
If you want manual control, you can add the ou-marquee-duplicate attribute to the wrapper.
220This tells the script exactly how many total copies to use. This is useful for very large layouts or specific visual styles. If you’re not sure whether you need it, it’s best to leave this attribute unset and let the script handle duplication automatically.
Vertical marquees only work when the container has a fixed height to scroll against. You must set either a height or a max-height on the marquee wrapper.
If no height constraint is set, a vertical flex container will just expand to fit all its content. When that happens, nothing overflows, so there’s nothing for the marquee to scroll.
As long as the total height of all cloned items is taller than the container, the marquee has space to move and the animation works as expected. If the container keeps growing with its content, the marquee will look “stuck” even though the script is running.