Interactive
InfiniteMarquee
A strip that loops with no seam at any width, repeating itself only as often as needed. Eases to a stop on hover, and stands still as a hand-scrolled strip under reduced motion.
Download
- React
- Tailwind CSS v4
- motion
components/ui/infinite-marquee.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion } from "motion/react";
type InfiniteMarqueeProps = {
/** One run of the strip. It repeats as often as the width needs. */
children: ReactNode;
/** Pixels per second. */
speed?: number;
direction?: "left" | "right";
/** Eases to a stop under the pointer or on keyboard focus. */
pauseOnHover?: boolean;
/** Fades the two ends of the strip. */
fade?: boolean;
/** Space between pieces in pixels. */
gap?: number;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const MASK = "linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent)";
/* InfiniteMarquee: a strip that loops with no seam at any width. Slows to a stop on hover, stands still and scrolls by hand under reduced motion. */
export function InfiniteMarquee({
children,
speed = 60,
direction = "left",
pauseOnHover = true,
fade = true,
gap = 32,
label = "Scrolling strip",
className = "",
}: InfiniteMarqueeProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const run = useRef<HTMLDivElement>(null);
const seen = useInView(box);
const [copies, setCopies] = useState(2);
const x = useMotionValue(0);
const runWidth = useRef(0);
const travelled = useRef(0);
const pace = useRef(speed);
const held = useRef(false);
useEffect(() => {
if (!box.current || !run.current) return;
const measure = () => {
const full = box.current?.offsetWidth ?? 0;
const one = run.current?.offsetWidth ?? 0;
runWidth.current = one;
if (one) setCopies(Math.ceil(full / one) + 1);
};
const observer = new ResizeObserver(measure);
observer.observe(box.current);
observer.observe(run.current);
return () => observer.disconnect();
}, []);
useAnimationFrame((_, delta) => {
const one = runWidth.current;
if (reduce || !seen || !one) return;
pace.current += ((held.current ? 0 : speed) - pace.current) * Math.min(1, delta / 160);
travelled.current = (travelled.current + (pace.current * delta) / 1000) % one;
x.set(direction === "left" ? -travelled.current : travelled.current - one);
});
const hold = (on: boolean) => {
if (pauseOnHover) held.current = on;
};
const strip = (
<div className="flex w-max shrink-0 items-center" style={{ gap, paddingRight: gap }}>
{children}
</div>
);
return (
<div
ref={box}
role="group"
aria-label={label}
onPointerEnter={() => hold(true)}
onPointerLeave={() => hold(false)}
onFocus={() => hold(true)}
onBlur={() => hold(false)}
className={`${reduce ? "overflow-x-auto" : "overflow-hidden"} ${className}`}
style={fade ? { maskImage: MASK, WebkitMaskImage: MASK } : undefined}
>
<motion.div className="flex w-max" style={{ x }}>
<div ref={run} className="shrink-0">
{strip}
</div>
{!reduce &&
Array.from({ length: copies - 1 }, (_, i) => (
<div key={i} className="shrink-0" aria-hidden inert>
{strip}
</div>
))}
</motion.div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/infinite-marquee.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/infinite-marquee-demo.tsx
import { InfiniteMarquee } from "@/components/ui/infinite-marquee"; const TOP = ["Northwind", "Halcyon", "Lumen", "Arcadia", "Vertex"]; const BOTTOM = ["Fast to ship", "Copy and paste", "Keyboard ready", "Reduced motion", "Dark by default"]; export default function InfiniteMarqueeExample() { return ( <div className="grid h-full content-center gap-5 bg-[#0b0a14] py-6"> <InfiniteMarquee label="Customers" gap={40}> {TOP.map((name) => ( <span key={name} className="text-xl font-semibold text-white/70"> {name} </span> ))} </InfiniteMarquee> <InfiniteMarquee label="Features" direction="right" speed={40} gap={12}> {BOTTOM.map((text) => ( <span key={text} className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-[#a78bfa]"> {text} </span> ))} </InfiniteMarquee> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | One run of the strip. |
| speed | number | 60 | Pixels per second. |
| direction | "left" | "right" | "left" | Way the strip travels. |
| pauseOnHover | boolean | true | Eases to a stop under the pointer or on keyboard focus. |
| fade | boolean | true | Fades the two ends of the strip. |
| gap | number | 32 | Space between pieces in pixels. |
| label | string | "Scrolling strip" | Read by screen readers. |
| className | string | "" | On the strip wrapper. |