Galleries & Media

Marquee Gallery

An infinite dual-row media marquee scrolling in opposite directions, pausing on hover and offscreen, with a soft edge-fade mask.

Install

npx shadcn@latest add @paragon/marquee-gallery

marquee-gallery.tsx

"use client";

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

/**
 * MarqueeGallery — an infinite dual-row media marquee. Rows scroll in opposite
 * directions on a CSS keyframe (the track is duplicated so the loop is
 * seamless), pausing on hover and whenever the whole strip scrolls offscreen.
 * A symmetric edge-fade mask keeps the ends soft. Pure CSS translation, so it
 * stays smooth and cheap.
 *
 * Pass `items`; otherwise deterministic gradient tiles render. Reduced motion
 * stops the scroll and shows a static, legible strip.
 */
export interface MarqueeGalleryProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  items?: React.ReactNode[];
  /** Number of generated tiles when `items` is omitted. */
  count?: number;
  /** Number of rows. */
  rows?: number;
  /** Seconds for one full traversal (lower = faster). */
  speed?: number;
  /** Gap between tiles, px. */
  gap?: number;
  /** Tile width, px. */
  tileWidth?: number;
  /** Tile height, px. */
  tileHeight?: number;
  /** Pause the scroll while hovered. */
  pauseOnHover?: boolean;
}

const TILE_GRADIENTS = [
  "linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.52 0.2 285))",
  "linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.57 0.16 200))",
  "linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.66 0.19 40))",
  "linear-gradient(135deg, oklch(0.75 0.17 20), oklch(0.57 0.18 350))",
  "linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.54 0.16 264))",
  "linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.61 0.14 236))",
  "linear-gradient(135deg, oklch(0.8 0.15 130), oklch(0.61 0.17 160))",
  "linear-gradient(135deg, oklch(0.84 0.16 92), oklch(0.66 0.18 54))",
  "linear-gradient(135deg, oklch(0.74 0.15 330), oklch(0.55 0.18 300))",
  "linear-gradient(135deg, oklch(0.79 0.14 220), oklch(0.58 0.16 255))",
];

function GeneratedTile({ i }: { i: number }) {
  return (
    <div
      className="flex size-full items-end justify-start p-3"
      style={{ background: TILE_GRADIENTS[i % TILE_GRADIENTS.length] }}
    >
      <span className="text-sm font-semibold tabular-nums text-white/90 drop-shadow-[0_1px_3px_rgba(0,0,0,0.35)]">
        {String(i + 1).padStart(2, "0")}
      </span>
    </div>
  );
}

const STYLES = `
@keyframes paragon-marquee-left { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes paragon-marquee-right { from { transform: translateX(-50%); } to { transform: translateX(0); } }
.paragon-marquee-track { animation-timing-function: linear; animation-iteration-count: infinite; }
.paragon-marquee-paused .paragon-marquee-track { animation-play-state: paused !important; }
.paragon-marquee-root[data-hover-pause="true"]:hover .paragon-marquee-track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .paragon-marquee-track { animation: none !important; transform: none !important; }
}
`;

export function MarqueeGallery({
  items,
  count = 10,
  rows = 2,
  speed = 32,
  gap = 12,
  tileWidth = 150,
  tileHeight = 96,
  pauseOnHover = true,
  className,
  style,
  ...props
}: MarqueeGalleryProps) {
  const reduced = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [visible, setVisible] = React.useState(true);

  const tiles = React.useMemo<React.ReactNode[]>(() => {
    if (items && items.length) return items;
    return Array.from({ length: count }, (_, i) => (
      <GeneratedTile key={i} i={i} />
    ));
  }, [items, count]);

  const n = tiles.length;

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      ([entry]) => setVisible(entry.isIntersecting),
      { threshold: 0 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <>
      <style href="paragon-marquee-gallery" precedence="paragon">
        {STYLES}
      </style>
      <div
        ref={rootRef}
        data-hover-pause={pauseOnHover ? "true" : "false"}
        className={cn(
          "paragon-marquee-root w-full overflow-hidden",
          !visible && "paragon-marquee-paused",
          className,
        )}
        style={{
          maskImage:
            "linear-gradient(to right, transparent, black 8%, black 92%, transparent)",
          WebkitMaskImage:
            "linear-gradient(to right, transparent, black 8%, black 92%, transparent)",
          display: "flex",
          flexDirection: "column",
          rowGap: gap,
          ...style,
        }}
        {...props}
      >
        {Array.from({ length: rows }, (_, r) => {
          // A single set. Gap lives as a right margin on each tile so a set's
          // width is exactly (tileWidth + gap) * n — making the -50% translate
          // of the duplicated track perfectly seamless.
          const renderSet = (hidden: boolean) => (
            <div className="flex shrink-0" aria-hidden={hidden || undefined}>
              {Array.from({ length: n }, (_, k) => {
                const idx = (k + r * 3) % n;
                return (
                  <div
                    key={k}
                    className="shrink-0 overflow-hidden rounded-xl shadow-border"
                    style={{
                      width: tileWidth,
                      height: tileHeight,
                      marginRight: gap,
                    }}
                  >
                    {tiles[idx]}
                  </div>
                );
              })}
            </div>
          );
          const dir = r % 2 === 0 ? "left" : "right";
          return (
            <div key={r} className="flex overflow-hidden">
              <div
                className="paragon-marquee-track flex w-max shrink-0"
                style={{
                  animationName: reduced ? "none" : `paragon-marquee-${dir}`,
                  animationDuration: `${speed}s`,
                }}
              >
                {renderSet(false)}
                {renderSet(true)}
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
}