Text Effects

Marquee

An infinite horizontal scroll of children on a linear loop that pauses on hover and when offscreen.

Install

npx shadcn@latest add @paragon/marquee

marquee.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface MarqueeProps extends React.ComponentProps<"div"> {
  /** Seconds per full loop of the content. */
  duration?: number;
  /** Scroll left-to-right instead of the default right-to-left. */
  reverse?: boolean;
  /** Pause while hovered. Applies only on precise pointers. */
  pauseOnHover?: boolean;
  /** Pixel gap between items, also used at the loop seam. */
  gap?: number;
  /** Fade the horizontal edges so items enter and exit softly. */
  fade?: boolean;
  /** Render the children in a plain row with no motion. */
  static?: boolean;
}

/**
 * An infinite horizontal scroll of children on a linear loop.
 *
 * Technique: the children render twice on a single track that a CSS keyframe
 * translates from 0 to -50% — because both copies (each with a trailing
 * seam gap) are identical widths, the loop point is invisible. Linear easing
 * is correct here: constant motion is the one place it's allowed.
 *
 * Pauses on hover (precise pointers only), pauses offscreen via
 * IntersectionObserver, and freezes entirely under prefers-reduced-motion.
 * Provide enough children to overflow the container, or the seam gap shows.
 */
export function Marquee({
  duration = 30,
  reverse = false,
  pauseOnHover = true,
  gap = 48,
  fade = true,
  static: isStatic = false,
  className,
  style,
  children,
  ...props
}: MarqueeProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [offscreen, setOffscreen] = React.useState(false);

  React.useEffect(() => {
    if (isStatic) return;
    const node = ref.current;
    if (!node) return;
    const observer = new IntersectionObserver(([entry]) => {
      setOffscreen(!entry.isIntersecting);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [isStatic]);

  const maskStyle: React.CSSProperties = fade
    ? {
        maskImage:
          "linear-gradient(to right, transparent, black 10%, black 90%, transparent)",
        WebkitMaskImage:
          "linear-gradient(to right, transparent, black 10%, black 90%, transparent)",
      }
    : {};

  if (isStatic) {
    return (
      <div
        data-slot="marquee"
        className={cn("flex w-full items-center overflow-hidden", className)}
        style={{ gap, ...maskStyle, ...style }}
        {...props}
      >
        {children}
      </div>
    );
  }

  return (
    <div
      ref={ref}
      data-slot="marquee"
      data-marquee=""
      data-paused={offscreen || undefined}
      data-pause-on-hover={pauseOnHover || undefined}
      className={cn("flex w-full overflow-hidden", className)}
      style={
        {
          "--pg-marquee-duration": `${duration}s`,
          "--pg-marquee-direction": reverse ? "reverse" : "normal",
          ...maskStyle,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      <style href="paragon-marquee" precedence="paragon">{`
        @keyframes pg-marquee-scroll {
          from { transform: translateX(0); }
          to { transform: translateX(-50%); }
        }
        [data-marquee] > [data-marquee-track] {
          animation: pg-marquee-scroll var(--pg-marquee-duration, 30s) linear infinite;
          animation-direction: var(--pg-marquee-direction, normal);
        }
        [data-marquee][data-paused] > [data-marquee-track] {
          animation-play-state: paused;
        }
        @media (hover: hover) and (pointer: fine) {
          [data-marquee][data-pause-on-hover]:hover > [data-marquee-track] {
            animation-play-state: paused;
          }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-marquee] > [data-marquee-track] {
            animation: none;
          }
        }
      `}</style>
      <div data-marquee-track className="flex w-max will-change-transform">
        <div
          className="flex shrink-0 items-center"
          style={{ gap, paddingRight: gap }}
        >
          {children}
        </div>
        <div
          aria-hidden="true"
          className="flex shrink-0 items-center"
          style={{ gap, paddingRight: gap }}
        >
          {children}
        </div>
      </div>
    </div>
  );
}