Draggable Marquee

Marquee
Draggable
GSAP
Observer
JS
Last Updated: Dec 4, 2025
  • gap and padding-right on the marquee-4_list must match for the list to loop seamlessly
  • The second marquee-4_panel has an attribute of aria-hidden="true" so duplicated content isn't read by screen readers
  • When images are inside a marquee, they should be set to eager load to avoid flicker
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Observer.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
	gsap.registerPlugin(Observer);
	document.querySelectorAll(".marquee-4_component").forEach((component) => {
		if (component.hasAttribute("data-marquee-4")) return;
		component.setAttribute("data-marquee-4", "");

		const speed = 100;
		const button = component.querySelector(".marquee-4_button");
		const panels = component.querySelectorAll(".marquee-4_panel");
		if (!panels.length) return;
		const timeScale = { value: 1 };
		let playState = 1;
		let direction = 1;

		let tl = gsap.timeline({ repeat: -1, onReverseComplete: () => tl.progress(1), overwrite: true });
		tl.fromTo(panels, { xPercent: 0 }, { xPercent: -100, duration: Math.max(800, panels[0].offsetWidth) / speed, ease: "none" });

		Observer.create({
			target: component,
			type: "pointer,touch",
			onChangeX: (self) => {
				let v = self.velocityX * -0.01;
				v = gsap.utils.clamp(-30, 30, v);
				direction = v < 0 ? -1 : 1;
				let tl2 = gsap.timeline({ onUpdate: () => tl.timeScale(timeScale.value) });
				tl2.to(timeScale, { value: v, duration: 0.1 });
				tl2.to(timeScale, { value: direction * playState, duration: 1 });
			}
		});

		function setPaused(paused = true) {
			playState = paused ? 0.01 : 1;
			gsap.to(timeScale, { value: playState * direction, duration: 0.5, overwrite: true, onUpdate: () => tl.timeScale(timeScale.value) });
			button?.setAttribute("aria-pressed", paused ? "true" : "false");
		}
		button?.addEventListener("click", () => setPaused(button.getAttribute("aria-pressed") !== "true"));
		const motionReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
		setPaused(motionReduce.matches);
		motionReduce.addEventListener("change", e => setPaused(e.matches));
	});
});
</script>
  • Control the speed of the marquee from the following line. Speed is relative to the width of the panel.
const speed = 100;
  • Set the velocity multiplier from the following line. Changing -0.01 to -0.005 will make marquee move slower when dragging.
let v = self.velocityX * -0.01;
  • Set the max velocity from the following line. The default is 30. Larger numbers will make the marquee move even faster when dragged quickly.
v = gsap.utils.clamp(-30, 30, v);
  • Remove the following line if the direction should always go forwards after the user stops dragging
direction = v < 0 ? -1 : 1;