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.
*
* 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>
);
}