Skip to content
Skip to Content
DocsDraggable Marquee
Draggable Marquee

Draggable Marquee

A continuous image marquee with drag momentum and a seamless looping track.

Drag the images, or focus the marquee and use the left and right arrow keys.

Component preview
Draggable Marquee

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/draggable-marquee.json"

Usage

Code
"use client"; import { DraggableMarquee } from "@/components/block/draggable-marquee"; const items = [ { "id": 1, "src": "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/draggable-marquee/draggable-marquee-img01.jpg", "alt": "Landscape photograph 1", "width": 420, "height": 520, "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover" }, { "id": 2, "src": "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/draggable-marquee/draggable-marquee-img02.jpg", "alt": "Landscape photograph 2", "width": 420, "height": 520, "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover" }, { "id": 3, "src": "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/draggable-marquee/draggable-marquee-img03.jpg", "alt": "Landscape photograph 3", "width": 420, "height": 520, "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover" }, { "id": 4, "src": "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/draggable-marquee/draggable-marquee-img04.jpg", "alt": "Landscape photograph 4", "width": 420, "height": 520, "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover" } ]; export default function Demo() { return <DraggableMarquee items={items} speed={1} className="py-8" />; }

Install Manually

1

Install dependencies

npm install gsap
2

Copy the source code

Copy into components/block/draggable-marquee.jsx

"use client"; import { useEffect, useMemo, useRef } from "react"; import Image from "next/image"; import gsap from "gsap"; import Draggable from "gsap/Draggable"; import "@/lib/effects/draggable-marquee/styles.css"; if (typeof window !== "undefined") gsap.registerPlugin(Draggable); /** * @param {{items?: Array<{id?: string | number, src: string, alt?: string, width?: number, height?: number, imageClassName?: string}>, * speed?: number, repeatCount?: number, gapClassName?: string, className?: string, trackClassName?: string, * itemClassName?: string, pauseOnHover?: boolean, renderItem?: (item: {src: string, [key: string]: unknown}, index: number) => import('react').ReactNode, * throwMultiplier?: number, throwFriction?: number, maxThrowVelocity?: number, initialOffset?: number, * loopStart?: number, loopEndMultiplier?: number, label?: string}} props */ const DraggableMarquee = ({ items = [], speed = 1, repeatCount = 3, gapClassName = "gap-6", className = "", trackClassName = "", itemClassName = "rounded-2xl", pauseOnHover = false, renderItem, // motion controls throwMultiplier = 2.8, throwFriction = 0.975, maxThrowVelocity = 60, // loop controls initialOffset = 0, loopStart = 0, loopEndMultiplier = -1.02, label = "Image marquee. Drag or use the left and right arrow keys.", }) => { const rootRef = useRef(null); const trackRef = useRef(null); const dragRef = useRef(null); const duplicatedItems = useMemo(() => { return Array.from({ length: repeatCount }).flatMap(() => items); }, [items, repeatCount]); useEffect(() => { if (!rootRef.current || !trackRef.current || !items.length) return; const media = gsap.matchMedia(); media.add("(prefers-reduced-motion: no-preference)", () => { const root = rootRef.current; const track = trackRef.current; let singleSetWidth = 0; let x = initialOffset; let throwVelocity = 0; let isPointerOver = false; let isDragging = false; let lastDragX = 0; let lastDragTime = 0; let wrapValue = (value) => value; let resizeRaf = null; const observers = []; const setX = gsap.quickSetter(track, "x", "px"); const getGap = () => { const styles = window.getComputedStyle(track); return parseFloat(styles.columnGap || styles.gap || 0); }; const buildWrap = () => { const min = singleSetWidth * loopEndMultiplier; const max = loopStart; wrapValue = gsap.utils.wrap(min, max); }; const getProgressInLoop = () => { if (!singleSetWidth) return 0; const min = singleSetWidth * loopEndMultiplier; const max = loopStart; const range = max - min; if (!range) return 0; let wrapped = x; while (wrapped < min) wrapped += range; while (wrapped > max) wrapped -= range; return (wrapped - min) / range; }; const setProgressInLoop = (progress) => { if (!singleSetWidth) return; const min = singleSetWidth * loopEndMultiplier; const max = loopStart; const range = max - min; x = min + range * progress; x = wrapValue(x); setX(x); if (dragRef.current) { dragRef.current.x = x; } }; const measure = () => { const children = Array.from(track.children); const setSize = Math.floor(children.length / repeatCount); const firstSetChildren = children.slice(0, setSize); const gap = getGap(); if (!firstSetChildren.length) return; const prevProgress = getProgressInLoop(); const widths = firstSetChildren.reduce( (sum, child) => sum + child.getBoundingClientRect().width, 0 ); singleSetWidth = widths + gap * Math.max(0, firstSetChildren.length - 1); buildWrap(); if (!Number.isFinite(prevProgress)) { x = wrapValue(initialOffset); setX(x); } else { setProgressInLoop(prevProgress); } }; const scheduleMeasure = () => { if (resizeRaf) cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(() => { measure(); }); }; const update = () => { if (!isDragging) { if (!(pauseOnHover && isPointerOver)) { x -= speed; } x += throwVelocity; throwVelocity *= throwFriction; if (Math.abs(throwVelocity) < 0.01) { throwVelocity = 0; } } x = wrapValue(x); setX(x); }; measure(); dragRef.current = Draggable.create(track, { type: "x", allowContextMenu: true, dragClickables: true, onPress() { isDragging = true; throwVelocity = 0; this.x = x; lastDragX = this.x; lastDragTime = performance.now(); }, onDrag() { const now = performance.now(); const dx = this.x - lastDragX; const dt = now - lastDragTime; x = wrapValue(this.x); setX(x); this.x = x; if (dt > 0) { const sampledVelocity = (dx / dt) * 36.67; throwVelocity = gsap.utils.clamp( -maxThrowVelocity, maxThrowVelocity, sampledVelocity * throwMultiplier ); } lastDragX = this.x; lastDragTime = now; }, onRelease() { isDragging = false; }, })[0]; const handleMouseEnter = () => { isPointerOver = true; }; const handleMouseLeave = () => { isPointerOver = false; }; if (pauseOnHover) { root.addEventListener("mouseenter", handleMouseEnter); root.addEventListener("mouseleave", handleMouseLeave); } const handleResize = () => { scheduleMeasure(); }; const handleKeyDown = (event) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); x = wrapValue(x + (event.key === "ArrowLeft" ? 1 : -1) * root.clientWidth * 0.35); throwVelocity = 0; setX(x); }; root.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", handleResize); const children = Array.from(track.children); const setSize = Math.floor(children.length / repeatCount); const firstSetChildren = children.slice(0, setSize); const trackObserver = new ResizeObserver(() => { scheduleMeasure(); }); trackObserver.observe(track); observers.push(trackObserver); firstSetChildren.forEach((child) => { const ro = new ResizeObserver(() => { scheduleMeasure(); }); ro.observe(child); observers.push(ro); const imgs = child.querySelectorAll("img"); imgs.forEach((img) => { if (!img.complete) { img.addEventListener("load", scheduleMeasure, { once: false }); } }); }); gsap.ticker.add(update); return () => { root.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", handleResize); if (pauseOnHover) { root.removeEventListener("mouseenter", handleMouseEnter); root.removeEventListener("mouseleave", handleMouseLeave); } gsap.ticker.remove(update); if (resizeRaf) cancelAnimationFrame(resizeRaf); observers.forEach((observer) => observer.disconnect()); const cleanupChildren = Array.from(track.children); cleanupChildren.forEach((child) => { const imgs = child.querySelectorAll("img"); imgs.forEach((img) => { img.removeEventListener("load", scheduleMeasure); }); }); if (dragRef.current) { dragRef.current.kill(); dragRef.current = null; } }; }, rootRef); return () => media.revert(); }, [ items, speed, repeatCount, pauseOnHover, throwMultiplier, throwFriction, maxThrowVelocity, initialOffset, loopStart, loopEndMultiplier, ]); if (!items.length) return null; return ( <div ref={rootRef} tabIndex={0} role="region" aria-label={label} className={`obsidian-draggable-marquee relative w-full overflow-hidden cursor-grab outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring active:cursor-grabbing ${className}`} > <div ref={trackRef} className={`flex w-max items-center ${gapClassName} ${trackClassName}`} > {duplicatedItems.map((item, index) => ( <div key={`${item?.id || item?.src || "item"}-${index}`} className={`shrink-0 ${itemClassName}`} aria-hidden={index >= items.length} data-marquee-copy={index >= items.length ? "duplicate" : "original"} > {renderItem ? ( renderItem(item, index % items.length) ) : ( <Image src={item.src} alt={item.alt || "marquee-item"} width={item.width || 400} height={item.height || 500} className={item.imageClassName || "h-auto w-auto object-cover max-sm:w-[320px] max-sm:h-105"} /> )} </div> ))} </div> </div> ); }; export { DraggableMarquee };
3

Add the CSS file

Copy into lib/effects/draggable-marquee/styles.css

@media (prefers-reduced-motion: reduce) { .obsidian-draggable-marquee { overflow-x: auto; } .obsidian-draggable-marquee > div { transform: none !important; } .obsidian-draggable-marquee [data-marquee-copy="duplicate"] { display: none; } }

Preview behavior

Drag the images, or focus the marquee and use the left and right arrow keys. The effect stays inside its container and respects reduced motion preferences.

Last updated on