Copied!
It's on your clipboard.
You’ve been offline for 0 second. Please check your Internet connection.

Help Us Improve

Found an issue or have an idea? Let us know.
Select all that apply...
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Infinite Marquee (Horizontal & Vertical)
Preview
Download Template
Report Bug

Info

Resource
Sliders & Marquees
Builder
Elementor
Add-ons
Elementor Pro
Last Updated
Jan 8, 2026

Overview

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.

  • If the wrapper uses flex-direction: row, the marquee scrolls horizontally.
  • If it uses 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.

Features

Setup

01
Add External Scripts

Copy & paste the scripts before the </body> tag of your project. If you added them before for another setup, skip this step.

Language
Copy
<!-- GSAP -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
01
Copy structure to Elementor

Right-click in Elementor, choose “Paste from another site,” and while the popup is open, press cmd/ctrl + v to insert the layout.

Copy To Elementor
01
Add HTML

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.

Language
Copy
01
Add custom CSS

Paste the code through the page or site settings, or add it via Elementor → Custom Code (before </body>) for broader use.

Language
Copy
01
Add custom Javascript

Paste the script through Elementor → Custom Code (set to load after </body>) for site-wide or page-specific loading.

Language
Copy
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();
});
01
Add custom PHP

Place the PHP snippet inside your theme’s functions.php or using any code snippet to enable logic.

Language
Copy
01

Add Elements & Attributes

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.

How Scroll Direction Is Determined

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 marquee
  • flex-direction: column → vertical marquee

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

01
Publish and preview live
Some solutions only work on the live site. Always publish and test after each change, as results may not appear in the editor.

Implementation

Reversing the Direction

If you want the marquee to move in the opposite direction, add the ou-marquee-reverse attribute to the wrapper.

  • Set it to true to reverse the direction
  • Leave it unset or set to false for the default direction

This works for both horizontal and vertical marquees and does not affect speed or spacing.

Controlling the Speed

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)
  • Less than 1 = slower (example: 0.7)
  • More than 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 second
  • ou-marquee-speed="0.5" moves at about 50px per second
  • ou-marquee-speed="2" moves at about 200px per second

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

Pausing When Out of View

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-screen

This is mainly a performance feature and is recommended.

Controlling Item Duplication (Optional)

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.

  • Minimum value is 2
  • Maximum value is 20

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

Troubleshooting: Vertical marquees need a height constraint

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.