An infinite dual-row media marquee scrolling in opposite directions, pausing on hover and offscreen, with a soft edge-fade mask.
npx shadcn@latest add @paragon/marquee-gallery"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>
</>
);
}