Skip to content
Corsair UI
Browse the docs

MotionNo. 100 of 148

Marquee

Content scrolling in a seamless loop, pausing on focus and off screen, and draggable by the pointer.

Installation

pnpm dlx shadcn@latest add @corsair-ui/marquee

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsuse-in-viewuse-media-query
npm
clsxtailwind-merge

Examples

Ports of call

Hover to pause; tabbing to a link stops it too.

Both ways

AnchorCompassShipSailboatLife buoyFlag
AnchorCompassShipSailboatLife buoyFlag

Vertical

Salvadorberth 1
Cartagenaberth 2
Havanaberth 3
Nassauberth 4
Port Royalberth 5
Tortugaberth 6
Veracruzberth 7

Draggable

Grab it and drag either way; a plain click still follows a link.

Paused from outside

paused holds it still, and it carries on from the same place.

AnchorCompassShipSailboatLife buoyFlag

API

PropTypeDefault
direction

Which way the content travels.

"left" | "right" | "up" | "down""left"
duration

Seconds to travel its own length.

number20
gap

Space between items, any CSS length.

string"1rem"
pauseOnHover

Hold still under the pointer.

booleanfalse
fade / fadeAmount

Fade the edges, in %.

boolean / numbertrue / 10
repeat

Copies in the loop; raise it for short content.

number2
draggable

Grab the loop and drag it either way; it carries on from where it is let go. A press that moves under 5 px is still a click.

booleanfalse
label

Names the scroll box it becomes with reduced motion.

string"Scrolling content"
paused

Hold it still from outside, such as while a dialog is open.

booleanfalse

Styling

Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=marquee-group]]:text-primary".

  • With draggable the root has data-draggable, and data-dragging="true" while it is held; the track pauses then.
data-slot
  • marquee
  • marquee-group
  • marquee-track
State
  • data-directioncomputed
  • data-draggablecomputed
  • data-pausedcomputed

Accessibility

  • The copies are aria-hidden and inert: one set of links for keyboards and screen readers.
  • It stops while a link inside has focus, and pauses off screen.
  • With prefers-reduced-motion it holds still and scrolls by hand, and draggable does nothing.
  • Dragging is an extra for pointers: nothing is only reachable by dragging, and the click that ends a drag does not follow a link.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")
Motion
Respects prefers-reduced-motion

Source

1 file, installed as source you own
marquee.tsxView on GitHub
"use client";

import {
  useEffect,
  useMemo,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type DragEvent,
  type MouseEvent,
  type PointerEvent,
  type Ref,
} from "react";

import { useInView } from "@/registry/default/hooks/use-in-view";
import { useMediaQuery } from "@/registry/default/hooks/use-media-query";
import { cn } from "@/registry/default/lib/utils";

function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return (node: T | null) => {
    for (const ref of refs) {
      if (typeof ref === "function") ref(node);
      else if (ref) ref.current = node;
    }
  };
}

/** How far, in px, the pointer moves before a press becomes a drag, so taps and clicks on links still work. */
const DRAG_THRESHOLD = 5;

interface MarqueeProps extends Omit<ComponentProps<"div">, "draggable"> {
  /** Which way the content travels. */
  direction?: "left" | "right" | "up" | "down";
  /** Seconds for the content to travel its own length. */
  duration?: number;
  /** Space between items, and between the end of the content and its next copy. Any CSS length. */
  gap?: string;
  /** Hold still while the pointer is over it. */
  pauseOnHover?: boolean;
  /**
   * Hold it still from outside, for example while a dialog opened from it
   * is up. It carries on from the same place once this is false again.
   */
  paused?: boolean;
  /** Fade the content out at both edges. */
  fade?: boolean;
  /** How much of each edge the fade covers, in percent. */
  fadeAmount?: number;
  /**
   * Copies of the content in the loop. Two are enough when the content is
   * at least as long as the marquee; raise it for short content in a wide one.
   */
  repeat?: number;
  /**
   * Names the marquee for screen readers when `prefers-reduced-motion` turns
   * it into a box you scroll by hand: it then becomes a focusable region, so
   * keyboard users can scroll it too.
   */
  label?: string;
  /**
   * Let the pointer grab the loop and drag it either way; it carries on from
   * where it is let go. A press that moves less than a few pixels is still a
   * click, so links inside keep working. With `prefers-reduced-motion` the
   * marquee is a scroll box instead, and this does nothing.
   */
  draggable?: boolean;
}

/** The loop's CSS animation on the track, where the browser supports looking it up. */
function loopAnimation(track: HTMLElement | null) {
  return track
    ?.getAnimations?.()
    .find((animation) => (animation as CSSAnimation).animationName?.startsWith("marquee"));
}

/**
 * Content that scrolls in an endless loop: logos, quotes, ports of call.
 * It is one CSS animation of a transform. The copies that make the loop
 * seamless are hidden from screen readers and cannot take focus; the loop
 * stops while a link inside has keyboard focus, and pauses off screen.
 * With `prefers-reduced-motion` it holds still and scrolls by hand instead.
 * With `draggable`, the pointer can grab the loop: dragging seeks the same
 * CSS animation, so the loop stays seamless and there are no scroll listeners.
 *
 * @example
 * <Marquee pauseOnHover gap="3rem">
 *   <span>Salvador</span>
 *   <span>Cartagena</span>
 *   <span>Havana</span>
 * </Marquee>
 */
function Marquee({
  direction = "left",
  duration = 20,
  gap = "1rem",
  pauseOnHover = false,
  paused = false,
  fade = true,
  fadeAmount = 10,
  repeat = 2,
  label = "Scrolling content",
  draggable = false,
  className,
  style,
  ref,
  children,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onClickCapture,
  onDragStart,
  ...props
}: MarqueeProps) {
  const [observe, inView] = useInView<HTMLDivElement>({ rootMargin: "100px" });
  const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
  const root = useRef<HTMLDivElement>(null);
  const mergedRef = useMemo(() => mergeRefs(ref, observe, root), [ref, observe]);
  // A scroll box has to be reachable from the keyboard. Set on the element, and
  // only while it is one, so the moving marquee stays out of the tab order.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    if (reduced) element.tabIndex = 0;
    else element.removeAttribute("tabindex");
  }, [reduced]);

  const vertical = direction === "up" || direction === "down";
  const reversed = direction === "right" || direction === "down";
  const copies = Math.max(2, Math.floor(repeat));

  const track = useRef<HTMLDivElement>(null);
  const drag = useRef<{ pointerId: number; start: number; last: number; moving: boolean }>(null);
  // Set when a drag ends, so the click the browser fires after it does not follow a link.
  const dragged = useRef(false);
  const position = (event: PointerEvent<HTMLElement>) => (vertical ? event.clientY : event.clientX);

  // Moves the loop by `distance` px by seeking its animation. Setting the
  // time (rather than calling pause or play) keeps CSS in charge of pausing.
  const seek = (distance: number) => {
    const animation = loopAnimation(track.current);
    if (!animation || !track.current) return;
    const box = track.current.getBoundingClientRect();
    const length = (vertical ? box.height : box.width) / copies;
    const period = Number(animation.effect?.getComputedTiming().duration);
    if (!length || !period) return;
    // Forwards, the track moves towards the start as time runs; reversed, away from it.
    const time =
      Number(animation.currentTime ?? 0) + (distance / length) * period * (reversed ? 1 : -1);
    animation.currentTime = ((time % period) + period) % period;
  };

  const endDrag = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
    const current = drag.current;
    if (!current || current.pointerId !== event.pointerId) return;
    drag.current = null;
    if (!current.moving) return;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    event.currentTarget.removeAttribute("data-dragging");
    dragged.current = !cancelled;
  };

  const dragHandlers = draggable
    ? {
        onPointerDown(event: PointerEvent<HTMLDivElement>) {
          onPointerDown?.(event);
          dragged.current = false;
          if (event.defaultPrevented || event.button !== 0 || drag.current) return;
          const start = position(event);
          drag.current = { pointerId: event.pointerId, start, last: start, moving: false };
        },
        onPointerMove(event: PointerEvent<HTMLDivElement>) {
          onPointerMove?.(event);
          const current = drag.current;
          if (!current || current.pointerId !== event.pointerId) return;
          const now = position(event);
          if (!current.moving) {
            if (Math.abs(now - current.start) < DRAG_THRESHOLD) return;
            // Only now, so a plain click still lands on the link under the pointer.
            current.moving = true;
            event.currentTarget.setPointerCapture(event.pointerId);
            event.currentTarget.setAttribute("data-dragging", "true");
            // Pressing a link focuses it, and focus inside pauses the loop; a drag is not a visit.
            const focused = document.activeElement;
            if (focused instanceof HTMLElement && event.currentTarget.contains(focused)) {
              focused.blur();
            }
          }
          seek(now - current.last);
          current.last = now;
        },
        onPointerUp(event: PointerEvent<HTMLDivElement>) {
          onPointerUp?.(event);
          endDrag(event, false);
        },
        onPointerCancel(event: PointerEvent<HTMLDivElement>) {
          onPointerCancel?.(event);
          endDrag(event, true);
        },
        onClickCapture(event: MouseEvent<HTMLDivElement>) {
          onClickCapture?.(event);
          if (!dragged.current) return;
          dragged.current = false;
          event.preventDefault();
          event.stopPropagation();
        },
        // Links and images would otherwise start a native drag and steal the pointer.
        onDragStart(event: DragEvent<HTMLDivElement>) {
          onDragStart?.(event);
          event.preventDefault();
        },
      }
    : { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onDragStart };
  const edge = Math.min(Math.max(fadeAmount, 0), 50);
  const mask = fade
    ? `linear-gradient(to ${vertical ? "bottom" : "right"}, transparent, #000 ${edge}%, #000 ${100 - edge}%, transparent)`
    : undefined;

  return (
    <div
      ref={mergedRef}
      data-slot="marquee"
      data-direction={direction}
      data-paused={paused ? "" : undefined}
      data-draggable={draggable ? "" : undefined}
      // Still, it scrolls by hand: a named region, focusable (set below) for the keyboard.
      role={reduced ? "region" : undefined}
      aria-label={reduced ? label : undefined}
      className={cn(
        "group/marquee flex overflow-hidden motion-reduce:overflow-auto",
        vertical && "flex-col",
        // Touch pans across the loop go to the drag; the page still scrolls the other way.
        draggable && "motion-safe:cursor-grab motion-safe:select-none",
        draggable && (vertical ? "motion-safe:touch-pan-x" : "motion-safe:touch-pan-y"),
        draggable && "data-[dragging=true]:cursor-grabbing",
        className
      )}
      style={
        {
          "--marquee-gap": gap,
          "--marquee-copies": copies,
          maskImage: mask,
          WebkitMaskImage: mask,
          ...style,
        } as CSSProperties
      }
      {...props}
      {...dragHandlers}
    >
      <div
        ref={track}
        data-slot="marquee-track"
        className={cn(
          "flex w-max shrink-0 group-focus-within/marquee:[animation-play-state:paused]",
          // Held while dragged; the drag moves it by seeking the animation.
          "group-data-[dragging=true]/marquee:[animation-play-state:paused]",
          vertical
            ? "motion-safe:animate-marquee-y h-max w-full flex-col"
            : "motion-safe:animate-marquee-x",
          pauseOnHover && "group-hover/marquee:[animation-play-state:paused]"
        )}
        style={{
          // Each loop moves the track by one copy of the content.
          animationDuration: `${duration}s`,
          animationDirection: reversed ? "reverse" : undefined,
          // Inline only when held or off screen, so hover and focus can still pause it.
          animationPlayState: paused || !inView ? "paused" : undefined,
        }}
      >
        {Array.from({ length: copies }, (_, copy) => (
          <div
            key={copy}
            data-slot="marquee-group"
            aria-hidden={copy > 0 ? true : undefined}
            inert={copy > 0 ? true : undefined}
            className={cn(
              "flex shrink-0 gap-[var(--marquee-gap)]",
              // Trailing space the size of the gap, so every copy is the same length and the loop has no seam.
              vertical
                ? "flex-col pb-[var(--marquee-gap)]"
                : "items-center pr-[var(--marquee-gap)]",
              copy > 0 && "motion-reduce:hidden"
            )}
          >
            {children}
          </div>
        ))}
      </div>
    </div>
  );
}

export { Marquee, type MarqueeProps };