layout·free
Marquee
Seamless infinite scrolling strip with hover slow-down and edge fade.
Next.jsGSAPReact Three FiberTailwindTypeScriptVercel
Installation
terminal
pnpm add gsap @gsap/reactThen copy the component below into src/components/motion/marquee.tsx. It expects @/lib/gsap (plugin registration) and @/lib/utils (cn).
Source
src/components/motion/marquee.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { gsap, useGSAP } from "@/lib/gsap";
import { cn } from "@/lib/utils";
export interface MarqueeProps {
children: ReactNode;
className?: string;
/** Pixels per second. */
speed?: number;
reverse?: boolean;
pauseOnHover?: boolean;
gap?: number;
}
export function Marquee({
children,
className,
speed = 80,
reverse = false,
pauseOnHover = true,
gap = 48,
}: MarqueeProps) {
const ref = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const t = track.current;
if (!t) return;
const width = t.scrollWidth / 2;
const tween = gsap.to(t, {
x: reverse ? 0 : -width,
duration: width / speed,
ease: "none",
repeat: -1,
...(reverse ? { startAt: { x: -width } } : {}),
});
if (!pauseOnHover) return;
const el = ref.current!;
const slow = () => gsap.to(tween, { timeScale: 0, duration: 0.6 });
const resume = () => gsap.to(tween, { timeScale: 1, duration: 0.6 });
el.addEventListener("mouseenter", slow);
el.addEventListener("mouseleave", resume);
return () => {
el.removeEventListener("mouseenter", slow);
el.removeEventListener("mouseleave", resume);
};
},
{ scope: ref, dependencies: [speed, reverse, pauseOnHover, gap] }
);
return (
<div
ref={ref}
className={cn("overflow-hidden [mask-image:linear-gradient(90deg,transparent,#000_10%,#000_90%,transparent)]", className)}
>
<div ref={track} className="flex w-max will-change-transform" style={{ gap }}>
<div className="flex shrink-0 items-center" style={{ gap }}>
{children}
</div>
<div className="flex shrink-0 items-center" style={{ gap }} aria-hidden>
{children}
</div>
</div>
</div>
);
}