An infinite horizontal scroll of children on a linear loop that pauses on hover and when offscreen.
npx shadcn@latest add @paragon/marquee"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>
);
}