An endless horizontal or vertical scroller for logo rows and testimonials. Speed is in pixels per second, it pauses on hover and focus, and it fills any width.
Trusted by product teams
import { Marquee } from "@/components/ballmac/marquee"
const logos = ["Northwind", "Acme", "Lumen", "Halcyon", "Meridian", "Parallax", "Quanta", "Tessellate"]
export default function MarqueeDemo() {
return (
<div className="w-full max-w-2xl">
<p className="mb-4 text-center font-mono text-xs tracking-widest text-muted-foreground uppercase">
Trusted by product teams
</p>
<Marquee gap={40}>
{logos.map((name) => (
<span key={name} className="flex items-center gap-2 text-lg font-semibold tracking-tight text-muted-foreground">
<span aria-hidden="true" className="size-4 rounded-sm border border-foreground/30 bg-muted" />
{name}
</span>
))}
</Marquee>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/marqueeInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/marquee.tsx// Ballmac UI: Marquee. https://ui.ballmac.com/components/marquee "use client" import * as React from "react" import { animate, type AnimationPlaybackControls } from "motion/react" import { cn } from "@/lib/utils" type MarqueeProps = React.ComponentProps<"div"> & { /** Scroll top to bottom instead of left to right. Give the marquee a height. */ vertical?: boolean /** Run the other way (right to left becomes left to right, up becomes down). */ reverse?: boolean /** Speed in pixels per second, so long and short content move at the same pace. */ speed?: number /** Pause while the pointer is over the marquee. Keyboard focus inside always pauses. */ pauseOnHover?: boolean /** Space between items and between loops. A number is pixels; strings are any CSS length. */ gap?: number | string /** Fade the leading and trailing edges into the background. */ fade?: boolean } const REDUCED_QUERY = "(prefers-reduced-motion: reduce)" function subscribeReducedMotion(onChange: () => void) { const media = window.matchMedia(REDUCED_QUERY) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) } /** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */ function useReducedMotionSafe() { return React.useSyncExternalStore( subscribeReducedMotion, () => window.matchMedia(REDUCED_QUERY).matches, () => false ) } const MAX_COPIES = 12 function Marquee({ vertical = false, reverse = false, speed = 40, pauseOnHover = true, gap = 16, fade = true, className, style, children, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: MarqueeProps) { const rootRef = React.useRef<HTMLDivElement>(null) const trackRef = React.useRef<HTMLDivElement>(null) const firstCopyRef = React.useRef<HTMLDivElement>(null) const controls = React.useRef<AnimationPlaybackControls | null>(null) const hovered = React.useRef(false) const focused = React.useRef(false) const reduceMotion = useReducedMotionSafe() // One copy's length (content + trailing gap) and how many copies fill the viewport. const [loop, setLoop] = React.useState({ length: 0, copies: 2 }) React.useEffect(() => { const root = rootRef.current const copy = firstCopyRef.current if (!root || !copy || reduceMotion) return const measure = () => { const length = vertical ? copy.offsetHeight : copy.offsetWidth const viewport = vertical ? root.clientHeight : root.clientWidth // Capped: in a container that sizes to its content, each copy would widen the viewport and loop forever. const copies = length > 0 ? Math.min(MAX_COPIES, Math.max(2, Math.ceil(viewport / length) + 1)) : 2 setLoop((prev) => (prev.length === length && prev.copies === copies ? prev : { length, copies })) } measure() const observer = new ResizeObserver(measure) observer.observe(root) observer.observe(copy) return () => observer.disconnect() }, [vertical, reduceMotion]) React.useEffect(() => { const track = trackRef.current if (!track || reduceMotion || loop.length <= 0 || speed <= 0) return const axis = vertical ? "translateY" : "translateX" const start = `${axis}(0px)` const end = `${axis}(${-loop.length}px)` controls.current = animate( track, { transform: reverse ? [end, start] : [start, end] }, { duration: loop.length / speed, ease: "linear", repeat: Infinity } ) if (focused.current || (pauseOnHover && hovered.current)) controls.current.pause() return () => { controls.current?.stop() controls.current = null track.style.transform = "" } }, [loop.length, speed, reverse, vertical, reduceMotion, pauseOnHover]) const sync = () => { const paused = focused.current || (pauseOnHover && hovered.current) if (paused) controls.current?.pause() else controls.current?.play() } const copies = reduceMotion ? 1 : loop.copies const gapValue = typeof gap === "number" ? `${gap}px` : gap const fadeMask = fade ? `linear-gradient(${vertical ? "to bottom" : "to right"}, transparent, black 10%, black 90%, transparent)` : undefined return ( <div ref={rootRef} data-slot="marquee" // Scrollable instead of animated under reduced motion, so keyboard users need to reach it. tabIndex={reduceMotion ? 0 : undefined} data-orientation={vertical ? "vertical" : "horizontal"} className={cn( "relative flex max-w-full min-w-0", vertical ? "flex-col" : "flex-row", reduceMotion ? cn(vertical ? "overflow-y-auto" : "overflow-x-auto", "outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50") : "overflow-hidden", className )} style={{ "--marquee-gap": gapValue, maskImage: fadeMask, WebkitMaskImage: fadeMask, ...style } as React.CSSProperties} onPointerEnter={(event) => { hovered.current = true sync() onPointerEnter?.(event) }} onPointerLeave={(event) => { hovered.current = false sync() onPointerLeave?.(event) }} onFocus={(event) => { focused.current = true sync() onFocus?.(event) }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { focused.current = false sync() } onBlur?.(event) }} {...props} > <div ref={trackRef} data-slot="marquee-track" className={cn("flex shrink-0", vertical ? "h-max flex-col" : "w-max flex-row")} > {Array.from({ length: copies }, (_, i) => ( <div key={i} ref={i === 0 ? firstCopyRef : undefined} data-slot="marquee-content" aria-hidden={i > 0 || undefined} inert={i > 0 || undefined} className={cn( "flex shrink-0 gap-(--marquee-gap)", vertical ? "flex-col pb-(--marquee-gap)" : "flex-row pe-(--marquee-gap)" )} > {children} </div> ))} </div> </div> ) } export { Marquee, type MarqueeProps }Update the import paths to match your project setup.
Usage
import { Marquee } from "@/components/ballmac/marquee"The full example is in the Code tab above.
Examples
Vertical testimonials
“We replaced three internal widgets in an afternoon.”
“The motion is quiet enough for a finance dashboard.”
“Dark mode worked on the first try. That never happens.”
import { Marquee } from "@/components/ballmac/marquee"
const quotes = [
{ body: "We replaced three internal widgets in an afternoon.", name: "Priya N.", role: "Frontend lead" },
{ body: "The motion is quiet enough for a finance dashboard.", name: "Tomás R.", role: "Product designer" },
{ body: "Dark mode worked on the first try. That never happens.", name: "Mei L.", role: "Staff engineer" },
{ body: "Our agents pick the right component from the docs.", name: "Jonah K.", role: "Platform engineer" },
{ body: "Accessible defaults saved us an audit cycle.", name: "Sara O.", role: "Accessibility lead" },
{ body: "It reads like our own code, not a black box.", name: "Luis M.", role: "Founding engineer" },
]
function Quote({ body, name, role }: (typeof quotes)[number]) {
return (
<figure className="w-64 rounded-xl border bg-card p-4 text-sm text-card-foreground">
<blockquote>“{body}”</blockquote>
<figcaption className="mt-3 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{name}</span> · {role}
</figcaption>
</figure>
)
}
export default function MarqueeVertical() {
return (
<div className="flex h-80 w-full max-w-xl justify-center gap-4 overflow-hidden">
<Marquee vertical speed={24} gap={12} className="h-full">
{quotes.slice(0, 3).map((q) => (
<Quote key={q.name} {...q}/>
))}
</Marquee>
<Marquee vertical reverse speed={24} gap={12} className="hidden h-full sm:flex">
{quotes.slice(3).map((q) => (
<Quote key={q.name} {...q}/>
))}
</Marquee>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
verticalScroll top to bottom instead of left to right. Give the marquee a height. | boolean | false |
reverseRun the other way (right to left becomes left to right, up becomes down). | boolean | false |
speedSpeed in pixels per second, so long and short content move at the same pace. | number | 40 |
pauseOnHoverPause while the pointer is over the marquee. Keyboard focus inside always pauses. | boolean | true |
gapSpace between items and between loops. A number is pixels; strings are any CSS length. | number | string | 16 |
fadeFade the leading and trailing edges into the background. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves focus into the first copy of the content and pauses the scroll |
Use with AI
Put the items directly inside <Marquee>; it measures one copy, clones as many as the viewport needs (clones are aria-hidden and inert) and loops them with a GPU transform. Under reduced motion it renders a single copy in a scrollable row. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Marquee (@ballmac/marquee) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A row of customer or partner logos under a hero
- A wall of short testimonials (vertical, several columns side by side)
- A ticker of changelog items, integrations or stats
Not for
- Content people must read or act on in full (use a grid or carousel with controls)
- Critical navigation or calls to action
Registry JSON: https://ui.ballmac.com/r/marquee.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Spotlight Card
A card whose surface and border pick up a soft glow that follows the pointer, driven by CSS variables with no re-render per move. Keyboard focus shows a steady glow.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.