Marquee
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.
 *
 * The seam is clipped with `overflow-x: clip` rather than `overflow-hidden`,
 * so item borders, rings, and box-shadows are never sliced off the top or
 * bottom of the row — `clip` confines only the horizontal axis and leaves the
 * vertical free (unlike `hidden`, which would force `overflow-y: auto`). The
 * edge fade is applied on an inner layer so the mask feathers the content
 * without cropping the ring bloom either.
 *
 * 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]);

  // `overflow-x: clip` hides the seam horizontally without forcing the vertical
  // axis to scroll/clip — so rings and box-shadows on items keep their bloom
  // above and below the row. Block padding gives that bloom headroom; the
  // horizontal-only fade mask is fully opaque vertically, so within the padded
  // box nothing is cropped top or bottom.
  const baseStyle: React.CSSProperties = {
    overflowX: "clip",
    overflowY: "visible",
    paddingBlock: 6,
    ...(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", className)}
        style={{ gap, ...baseStyle, ...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", className)}
      style={
        {
          "--pg-marquee-duration": `${duration}s`,
          "--pg-marquee-direction": reverse ? "reverse" : "normal",
          ...baseStyle,
          ...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 items-center 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>
  );
}