Marquee
Content that scrolls sideways forever at a steady speed, with an optional glide to a stop on hover.
From the Nitip template — see it in contextNorthwindLumenArcadiaKestrelHaldenOakmereNorthwindLumenArcadiaKestrelHaldenOakmere
import { Marquee } from "@/components/ui/marquee";
const clients = ["Northwind", "Lumen", "Arcadia", "Kestrel", "Halden", "Oakmere"];
export default function MarqueeDemo() {
return (
<Marquee pauseOnHover fade gap={48} className="w-full">
{clients.map((name) => (
<span key={name} className="text-2xl font-semibold tracking-tight text-muted-foreground">
{name}
</span>
))}
</Marquee>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/marquee.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/marquee.tsx."use client"; import * as React from "react"; import { animate, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /* * Marquee — content that scrolls sideways forever at a steady speed (from the Nitip "Ticker"). * Speed is in pixels per second, so it stays the same whatever the content width. * Optional: glide to a stop on hover, fade the edges. With reduced motion the row stands still. */ export type MarqueeProps = { children: React.ReactNode; /** Pixels per second. */ speed?: number; direction?: "left" | "right"; /** Space between items and between copies, in px. */ gap?: number; /** Copies of the children in each half of the loop; raise it when the content is narrower than the container. */ repeat?: number; /** Slow to a stop while hovered. */ pauseOnHover?: boolean; /** Fade both edges into the background. */ fade?: boolean; className?: string; }; export function Marquee({ children, speed = 40, direction = "left", gap = 32, repeat = 2, pauseOnHover = false, fade = false, className, }: MarqueeProps) { const track = React.useRef<HTMLDivElement>(null); const loop = React.useRef<Animation | null>(null); const reduce = useReducedMotion(); React.useEffect(() => { const el = track.current; if (!el || reduce) return; /* (Re)start on every size change, keeping the current position so a resize doesn't jump. */ function start() { if (!el) return; const duration = (el.scrollWidth / 2 / speed) * 1000; const previous = loop.current; const previousDuration = Number(previous?.effect?.getTiming().duration) || 1; const progress = previous ? (Number(previous.currentTime ?? 0) % previousDuration) / previousDuration : 0; const rate = previous?.playbackRate ?? 1; previous?.cancel(); const [from, to] = direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"]; const next = el.animate([{ transform: `translateX(${from})` }, { transform: `translateX(${to})` }], { duration, iterations: Infinity, easing: "linear", }); next.currentTime = progress * duration; next.playbackRate = rate; loop.current = next; } const observer = new ResizeObserver(start); observer.observe(el); return () => { observer.disconnect(); loop.current?.cancel(); loop.current = null; }; }, [speed, direction, reduce]); const brake = React.useRef<ReturnType<typeof animate> | null>(null); function glide(to: number) { const current = loop.current; if (!pauseOnHover || !current) return; brake.current?.stop(); brake.current = animate(current.playbackRate, to, { duration: 0.5, ease: "easeOut", onUpdate: (rate) => { current.playbackRate = rate; }, }); } const group = ( <div className="flex shrink-0 items-center" style={{ gap, paddingRight: gap }}> {Array.from({ length: repeat }, (_, i) => ( <React.Fragment key={i}>{children}</React.Fragment> ))} </div> ); return ( <div className={cn("overflow-hidden", className)} onPointerEnter={() => glide(0)} onPointerLeave={() => glide(1)} style={ fade ? { maskImage: "linear-gradient(to right, transparent, black 10%, black 90%, transparent)", WebkitMaskImage: "linear-gradient(to right, transparent, black 10%, black 90%, transparent)", } : undefined } > <div ref={track} className="flex w-max will-change-transform"> {group} <div aria-hidden className="contents" inert> {group} </div> </div> </div> ); }
Usage
import { Marquee } from "@/components/ui/marquee";
<Marquee speed={40} pauseOnHover fade>
{logos.map((logo) => <img key={logo.src} src={logo.src} alt={logo.name} className="h-8" />)}
</Marquee>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | number | 40 | Pixels per second — the same whatever the content width. |
| direction | "left" | "right" | "left" | Scroll direction. |
| gap | number | 32 | Space between items, in px. |
| repeat | number | 2 | Copies of the children per loop half; raise it for short content. |
| pauseOnHover | boolean | false | Glide to a stop while hovered, then speed back up. |
| fade | boolean | false | Fade both edges into the background. |
- With reduced motion enabled the row stands still.
- The second copy is hidden from screen readers and inert.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called Marquee (infinite horizontal ticker), using the Web Animations API. - Outer div overflow-hidden; inner track w-max flex with two identical groups of the children (second copy aria-hidden and inert). Each group repeats the children N times with a gap and padding-right equal to the gap, so the loop is seamless. - Animate the track translateX 0% → -50% (or reverse for direction right), linear, infinite. Duration = (track width / 2) / speed in px per second. Re-create the animation on resize (ResizeObserver), keeping the current progress so it doesn't jump. - pauseOnHover: tween the animation's playbackRate to 0 over 0.5s ease-out on pointer enter, back to 1 on leave. - fade: mask-image linear-gradient transparent → black 10% → black 90% → transparent. - Respect prefers-reduced-motion: no animation.











