Draggable Marquee

Marquee
Draggable
GSAP
Observer
JS
Last Updated: May 14, 2026
  • 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 dragThreshold = 10;
		const button = component.querySelector(".marquee-4_button");
		const panels = component.querySelectorAll(".marquee-4_panel");
		if (!panels.length) return;

		const links = component.querySelectorAll("a");
		links.forEach((link) => {
			link.setAttribute("draggable", "false");
			link.addEventListener("dragstart", (e) => e.preventDefault());
		});

		const timeScale = { value: 1 };
		let playState = 1;
		let direction = 1;
		let startX = 0;
		let startY = 0;
		let pointerDownX = 0;
		let pointerDownY = 0;

		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" });

		component.addEventListener("pointerdown", (e) => {
			pointerDownX = e.clientX;
			pointerDownY = e.clientY;
			startX = e.clientX;
			startY = e.clientY;
		});

		component.addEventListener("click", (e) => {
			const dx = e.clientX - pointerDownX;
			const dy = e.clientY - pointerDownY;
			if (Math.sqrt(dx * dx + dy * dy) >= dragThreshold) {
				e.preventDefault();
				e.stopPropagation();
			}
		}, true);

		Observer.create({
			target: component,
			type: "pointer,touch",
			onChangeX: (self) => {
				const dx = Math.abs(self.x - startX);
				const dy = Math.abs(self.y - startY);
				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;