Skip to content

Draggable Marquee

An infinite loop you can pause and scrub.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/draggable-marquee.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/draggable-marquee/draggable-marquee.tsx
    "use client";
    
    import { Children, useEffect, useRef, useState, type ComponentProps, type MouseEvent, type PointerEvent, type ReactNode } from "react";
    import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion } from "motion/react";
    import { Pause, Play } from "lucide-react";
    import { cn } from "@/lib/utils";
    
    export interface DraggableMarqueeProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Items in the loop; each child becomes one list item. */
      children: ReactNode;
      /** Scroll speed in px per second. @default 40 */
      speed?: number;
      /** Which way the loop travels. @default "left" */
      direction?: "left" | "right";
      /** Space between items in px. @default 16 */
      gap?: number;
      /** Slow to a stop while hovered or while focus is inside. @default true */
      pauseOnHover?: boolean;
      /** Let people drag (or swipe) to scrub; a flick carries on and eases back to speed. @default true */
      draggable?: boolean;
      /** Fade items out at both edges. @default true */
      fade?: boolean;
      /** Show a pause/play button (recommended for loops that run long). @default false */
      showControls?: boolean;
    }
    
    const EDGE_FADE = "linear-gradient(to right, transparent, #000 8%, #000 92%, transparent)";
    
    export function DraggableMarquee({
      children,
      speed = 40,
      direction = "left",
      gap = 16,
      pauseOnHover = true,
      draggable = true,
      fade = true,
      showControls = false,
      className,
      style,
      "aria-label": ariaLabel = "Scrolling list",
      ...props
    }: DraggableMarqueeProps) {
      const reduceMotion = useReducedMotion();
      const rootRef = useRef<HTMLDivElement>(null);
      const copyRef = useRef<HTMLUListElement>(null);
      const inView = useInView(rootRef);
      const [width, setWidth] = useState(0);
      const [copies, setCopies] = useState(2);
      const [paused, setPaused] = useState(false);
      const x = useMotionValue(0);
      const velocity = useRef(0);
      const hold = useRef({ hover: false, focus: false });
      const drag = useRef({ pending: false, active: false, moved: 0, lastX: 0, lastT: 0, v: 0 });
      const items = Children.toArray(children);
    
      // Links and images inside start the browser's own drag-and-drop, which would cancel the swipe.
      useEffect(() => {
        const el = rootRef.current;
        if (!el) return;
        const block = (event: DragEvent) => event.preventDefault();
        el.addEventListener("dragstart", block);
        return () => el.removeEventListener("dragstart", block);
      }, []);
    
      // One copy's width (items + trailing gap), and enough copies to cover the viewport with one to spare.
      useEffect(() => {
        const root = rootRef.current;
        const copy = copyRef.current;
        if (!root || !copy) return;
        const measure = () => {
          setWidth(copy.offsetWidth);
          setCopies(Math.max(2, Math.ceil(root.offsetWidth / Math.max(copy.offsetWidth, 1)) + 1));
        };
        const observer = new ResizeObserver(measure);
        observer.observe(root);
        observer.observe(copy);
        return () => observer.disconnect();
      }, []);
    
      // Keep x within one copy's width, so the loop never runs out.
      const wrap = (value: number) => (width ? ((value % width) - width) % width : value);
    
      useAnimationFrame((_, delta) => {
        if (!width || reduceMotion || !inView || drag.current.active) return;
        const dt = Math.min(delta, 64) / 1000;
        const holding = paused || (pauseOnHover && (hold.current.hover || hold.current.focus));
        const target = holding ? 0 : direction === "left" ? -speed : speed;
        // Ease toward the target speed: pauses glide to a stop, and a flick's momentum bleeds off.
        velocity.current += (target - velocity.current) * Math.min(1, dt * 2.5);
        x.set(wrap(x.get() + velocity.current * dt));
      });
    
      const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
        if (!draggable || reduceMotion || event.button !== 0) return;
        // Don't capture yet: a press without movement must still click the link or button underneath.
        drag.current = { pending: true, active: false, moved: 0, lastX: event.clientX, lastT: performance.now(), v: 0 };
      };
      const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
        const d = drag.current;
        if (d.pending && !d.active) {
          if (Math.abs(event.clientX - d.lastX) < 4) return;
          d.active = true;
          event.currentTarget.setPointerCapture(event.pointerId);
        }
        if (!d.active) return;
        const now = performance.now();
        const dx = event.clientX - d.lastX;
        d.moved += Math.abs(dx);
        d.v = d.v * 0.6 + (dx / Math.max(now - d.lastT, 1)) * 1000 * 0.4;
        d.lastX = event.clientX;
        d.lastT = now;
        x.set(wrap(x.get() + dx));
      };
      const endDrag = () => {
        drag.current.pending = false;
        if (!drag.current.active) return;
        drag.current.active = false;
        velocity.current = Math.max(-2400, Math.min(2400, drag.current.v));
      };
    
      return (
        <div
          ref={rootRef}
          role="region"
          aria-label={ariaLabel}
          className={cn(
            "relative overflow-hidden motion-reduce:overflow-x-auto",
            draggable && "cursor-grab touch-pan-y select-none active:cursor-grabbing motion-reduce:cursor-auto",
            className,
          )}
          style={{ ...style, ...(fade ? { maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE } : {}) }}
          onPointerEnter={(event) => event.pointerType === "mouse" && (hold.current.hover = true)}
          onPointerLeave={() => (hold.current.hover = false)}
          onFocus={() => (hold.current.focus = true)}
          onBlur={(event) => !event.currentTarget.contains(event.relatedTarget) && (hold.current.focus = false)}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
          // A drag shouldn't count as a click on whatever item it started on.
          onClickCapture={(event: MouseEvent) => {
            if (drag.current.moved > 6) {
              event.preventDefault();
              event.stopPropagation();
              drag.current.moved = 0;
            }
          }}
          {...props}
        >
          <motion.div className="flex w-max" style={{ x }}>
            {Array.from({ length: copies }, (_, copy) => (
              // Only the first copy is exposed to assistive tech; the rest exist to make the loop seamless.
              <ul
                key={copy}
                ref={copy === 0 ? copyRef : undefined}
                aria-hidden={copy > 0 || undefined}
                inert={copy > 0 || undefined}
                className={cn("flex shrink-0 items-center", copy > 0 && "motion-reduce:hidden")}
                style={{ gap, paddingRight: gap }}
              >
                {items.map((item, i) => (
                  <li key={i} className="shrink-0">
                    {item}
                  </li>
                ))}
              </ul>
            ))}
          </motion.div>
          {showControls ? (
            <button
              type="button"
              onClick={() => setPaused((p) => !p)}
              aria-label={paused ? "Play" : "Pause"}
              aria-pressed={paused}
              className="after:absolute after:-inset-1 absolute top-1/2 right-2 inline-flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-border bg-background/90 text-foreground shadow-sm backdrop-blur outline-none hover:bg-background focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hidden"
            >
              {paused ? <Play className="size-3.5" aria-hidden="true" /> : <Pause className="size-3.5" aria-hidden="true" />}
            </button>
          ) : null}
        </div>
      );
    }

Usage

import { DraggableMarquee } from "@/components/ui/draggable-marquee/draggable-marquee";

export function Testimonials() {
  return (
    <DraggableMarquee aria-label="Testimonials" showControls>
      {quotes.map((q) => <QuoteCard key={q.id} {...q} />)}
    </DraggableMarquee>
  );
}

API reference

PropTypeDefaultDescription
children*ReactNode–Items in the loop; each child becomes one list item.
speednumber40Scroll speed in px per second.
direction"left" | "right""left"Which way the loop travels.
gapnumber16Space between items in px.
pauseOnHoverbooleantrueSlow to a stop while hovered or while focus is inside.
draggablebooleantrueLet people drag (or swipe) to scrub; a flick carries on and eases back to speed.
fadebooleantrueFade items out at both edges.
showControlsbooleanfalseShow a pause/play button (recommended for loops that run long).

Also accepts every native <div> attribute, including ref and className.

  • Drag or swipe to scrub. A flick carries on with its momentum and eases back to the cruising speed, and a drag never triggers a click on the item it started on.
  • Enough copies are rendered to cover any width, and the loop wraps within one copy's width, so it never runs out. It pauses while offscreen.
  • Vertical page scrolling still works on touch: only horizontal drags are captured.

Accessibility

  • Only the first copy of the items is exposed to assistive tech; the copies that make the loop seamless are aria-hidden and inert.
  • It slows to a stop while hovered or while keyboard focus is inside, and showControls adds a pause button, so moving content can always be stopped.
  • With reduced motion enabled, nothing moves: the copies are hidden and the row becomes a normal horizontally scrollable list.