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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/Observer.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>Code
<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>.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;
}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
| Name | Type | Default | Description |
|---|---|---|---|
| data-draggable-marquee-init | string | "" | Add to the wrapper element to initialize the marquee. Set to "initialized" by the script once setup is complete. |
| data-draggable-marquee-collection | string | "" | 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-list | string | "" | 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-clone | string | "" | 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-duration | number | 20 | Controls how many seconds it takes the marquee to travel one full list-width before wrapping, setting the baseline loop speed. |
| data-multiplier | number | 40 | Caps the maximum absolute speed drag can apply, preventing extremely fast flicks from exceeding this limit in either direction. |
| data-sensitivity | number | 0.01 | Scales 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.