Draggable Marquee (Directional)

A looping marquee that responds to drag and touch input, shifting speed and direction based on pointer velocity, then easing back to its default travel direction.

marqueedraggablegsapobserverscrolltriggerloopvelocitytouch

Setup — External Scripts

GSAP 3.14.1
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
Observer Plugin
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Observer.min.js"></script>
ScrollTrigger Plugin
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<div data-draggable-marquee-init="" data-direction="left" data-duration="20" data-multiplier="35" data-sensitivity="0.01" class="draggable-marquee">
  <div data-draggable-marquee-collection="" class="draggable-marquee__collection">
    <div data-draggable-marquee-list="" class="draggable-marquee__list">
      <div class="draggable-marquee__item is--round">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b8b19fd3d316656d36_marquee-fruit-1.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b8d50d60981d906f71_marquee-fruit-2.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b7383ea5688964f10b_marquee-fruit-3.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item is--round">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b7c398823122b56766_marquee-fruit-4.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b7e249f3def94a048c_marquee-fruit-5.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item is--round">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b7b75b2b06a7e51ec3_marquee-fruit-6.avif" class="draggable-marquee__item-img">
      </div>
      <div class="draggable-marquee__item">
        <img draggable="false" loading="eager" src="https://cdn.prod.website-files.com/694b0fb876617b13bea76eb8/694bc0b866f40e1da7eb53ba_marquee-fruit-7.avif" class="draggable-marquee__item-img">
      </div>
    </div>
  </div>
</div>
styles.css
css
.draggable-marquee {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  flex: none;
  width: 100%;
  overflow: hidden;
}

.draggable-marquee__collection {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  flex: none;
  will-change: transform;
}

.draggable-marquee__list {
  display: flex;
  justify-content: flex-start;
  align-items: center;
  flex: none;
}

.draggable-marquee__item {
  width: 15em;
  aspect-ratio: 1;
  border-radius: 1.25em;
  margin-right: 1em;
  flex: none;
  overflow: hidden;
}

.draggable-marquee__item.is--round {
  border-radius: 100em;
}

.draggable-marquee__item-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
script.js
javascript
function initDraggableMarquee() {
  const wrappers = document.querySelectorAll("[data-draggable-marquee-init]");

  const getNumberAttr = (el, name, fallback) => {
    const value = parseFloat(el.getAttribute(name));
    return Number.isFinite(value) ? value : fallback;
  };

  wrappers.forEach((wrapper) => {
    if (wrapper.getAttribute("data-draggable-marquee-init") === "initialized") return;

    const collection = wrapper.querySelector("[data-draggable-marquee-collection]");
    const list = wrapper.querySelector("[data-draggable-marquee-list]");
    if (!collection || !list) return;

    const duration = getNumberAttr(wrapper, "data-duration", 20);
    const multiplier = getNumberAttr(wrapper, "data-multiplier", 40);
    const sensitivity = getNumberAttr(wrapper, "data-sensitivity", 0.01);

    const wrapperWidth = wrapper.getBoundingClientRect().width;
    const listWidth = list.scrollWidth || list.getBoundingClientRect().width;
    if (!wrapperWidth || !listWidth) return;

    // Make enough duplicates to cover screen
    const minRequiredWidth = wrapperWidth + listWidth + 2;
    while (collection.scrollWidth < minRequiredWidth) {
      const listClone = list.cloneNode(true);
      listClone.setAttribute("data-draggable-marquee-clone", "");
      listClone.setAttribute("aria-hidden", "true");
      collection.appendChild(listClone);
    }

    const wrapX = gsap.utils.wrap(-listWidth, 0);

    gsap.set(collection, { x: 0 });

    const marqueeLoop = gsap.to(collection, {
      x: -listWidth,
      duration,
      ease: "none",
      repeat: -1,
      onReverseComplete: () => marqueeLoop.progress(1),
      modifiers: {
        x: (x) => wrapX(parseFloat(x)) + "px"
      },
    });

    // Direction can be used for css + set initial direction on load
    const initialDirectionAttr = (wrapper.getAttribute("data-direction") || "left").toLowerCase();
    const baseDirection = initialDirectionAttr === "right" ? -1 : 1;

    const timeScale = { value: 1 };

    timeScale.value = baseDirection;
    wrapper.setAttribute("data-direction", baseDirection < 0 ? "right" : "left");

    if (baseDirection < 0) marqueeLoop.progress(1);

    function applyTimeScale() {
      marqueeLoop.timeScale(timeScale.value);
      wrapper.setAttribute("data-direction", timeScale.value < 0 ? "right" : "left");
    }

    applyTimeScale();

    // Drag observer
    const marqueeObserver = Observer.create({
      target: wrapper,
      type: "pointer,touch",
      preventDefault: true,
      debounce: false,
      onChangeX: (observerEvent) => {
        let velocityTimeScale = observerEvent.velocityX * -sensitivity;
        velocityTimeScale = gsap.utils.clamp(-multiplier, multiplier, velocityTimeScale);

        gsap.killTweensOf(timeScale);

        const restingDirection = velocityTimeScale < 0 ? -1 : 1;

        gsap.timeline({ onUpdate: applyTimeScale })
          .to(timeScale, { value: velocityTimeScale, duration: 0.1, overwrite: true })
          .to(timeScale, { value: restingDirection, duration: 1.0 });
      }
    });

    // Pause marquee when scrolled out of view
    ScrollTrigger.create({
      trigger: wrapper,
      start: "top bottom",
      end: "bottom top",
      onEnter: () => { marqueeLoop.resume(); applyTimeScale(); marqueeObserver.enable(); },
      onEnterBack: () => { marqueeLoop.resume(); applyTimeScale(); marqueeObserver.enable(); },
      onLeave: () => { marqueeLoop.pause(); marqueeObserver.disable(); },
      onLeaveBack: () => { marqueeLoop.pause(); marqueeObserver.disable(); }
    });

    wrapper.setAttribute("data-draggable-marquee-init", "initialized");
  });
}

// Initialize Draggable Marquee (Directional)
document.addEventListener("DOMContentLoaded", () => {
  initDraggableMarquee();
});

Attributes

NameTypeDefaultDescription
data-draggable-marquee-initstring""Add to the wrapper element to initialize the marquee. Set to "initialized" by the script once setup is complete.
data-draggable-marquee-collectionstring""Add to the moving container that gets translated on the x-axis. The marquee loop and drag-driven time scaling act on this element.
data-draggable-marquee-liststring""Add to the list element inside the collection. This list holds all items and is cloned as needed to fill the viewport for a seamless loop.
data-draggable-marquee-clonestring""Automatically added by the script to each duplicated list element. Clones also receive aria-hidden="true" to hide them from assistive technologies.
data-direction"left" | "right""left"Sets the initial travel direction on page load. The script keeps this attribute updated during interaction, allowing CSS to respond to the current direction.
data-durationnumber20Controls how many seconds it takes the marquee to travel one full list-width before wrapping, setting the baseline loop speed.
data-multipliernumber40Caps the maximum absolute speed drag can apply, preventing extremely fast flicks from exceeding this limit in either direction.
data-sensitivitynumber0.01Scales how strongly pointer velocity maps to marquee speed. Higher values feel more responsive; lower values feel heavier and smoother.

Notes

  • Images inside marquee items should have draggable="false" to prevent the browser's native image drag from interfering with the marquee drag interaction.
  • Set loading="eager" on marquee images to avoid layout shifts that could affect the cloning and width calculations on initialisation.
  • The script uses gsap.utils.wrap to create a seamless infinite loop by recycling the x position within the range of one list width.
  • The marquee is automatically paused via ScrollTrigger when it leaves the viewport, and resumed when it re-enters, reducing unnecessary offscreen work.
  • The Observer plugin listens for pointer and touch events. The drag velocity is clamped by data-multiplier and scaled by data-sensitivity before being applied as a GSAP timeScale.
  • After each drag interaction the time scale eases back to the base direction (1 for left, -1 for right) over 1 second.

Guide

Wrapper

Add [data-draggable-marquee-init] to the outermost container to mark it for initialisation. Set data-direction, data-duration, data-multiplier, and data-sensitivity here as needed.

Collection & List

Place [data-draggable-marquee-collection] on the direct child that moves, and [data-draggable-marquee-list] inside it holding all items. The script clones the list into the collection until it is wide enough to fill the viewport plus one extra list-width.

Images

Add draggable="false" and loading="eager" to all images inside the marquee. This prevents the browser's native image drag from conflicting with the Observer and ensures dimensions are available before the script calculates widths.

Drag behaviour

During a drag the Observer reads velocityX, scales it by data-sensitivity, clamps it within ±data-multiplier, and applies it as the GSAP timeScale. A negative time scale reverses direction. After release the scale tweens back to the resting direction over 1 second.

Direction attribute

The script writes the current direction ("left" or "right") back to data-direction on every update. Use a CSS attribute selector like [data-draggable-marquee-init][data-direction="right"] to style the marquee differently depending on travel direction.

Viewport pause

A ScrollTrigger instance watches the wrapper. The marquee loop and Observer are paused when the element is fully off-screen and resumed when it re-enters, saving CPU on long pages.