Marquee
pnpm dlx shadcn@latest add …/r/marquee.jsonAn accessible React marquee for Next.js and Tailwind CSS: an endless, seamless row of logos or cards that pauses on hover and stops with reduced motion.
“Setup took an afternoon, not a sprint.”
“It looked right in our theme on the first try.”
“The motion is quiet. Nothing shows off.”
“Keyboard support was already there.”
“We deleted half our custom CSS.”
import { Marquee } from "@/components/ui/marquee"
export function Logos() {
return (
<Marquee>
<span>Northwind</span>
<span>Atlas</span>
<span>Vertex</span>
</Marquee>
)
}"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Marquee } from "@/components/ui/marquee";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useId, useState } from "react";
/** Wordmarks set in type, each with its own weight and spacing, the way real logos differ */
const logos = [
{ name: "Northwind", style: "font-semibold tracking-tight" },
{ name: "ATLAS", style: "font-semibold tracking-wide" },
{ name: "orbit", style: "font-mono font-medium" },
{ name: "Kite & Co", style: "font-semibold italic" },
{ name: "Vertex", style: "font-bold tracking-tight" },
{ name: "harbor", style: "font-medium" },
{ name: "Quill", style: "font-semibold italic tracking-tight" },
{ name: "LUMEN", style: "font-medium tracking-wide" },
];
const quotes = [
{ quote: "Setup took an afternoon, not a sprint.", name: "Maya Chen", role: "Design lead" },
{
quote: "It looked right in our theme on the first try.",
name: "Tomás Rivera",
role: "Frontend engineer",
},
{ quote: "The motion is quiet. Nothing shows off.", name: "Priya Nair", role: "Product designer" },
{ quote: "Keyboard support was already there.", name: "Jonas Berg", role: "Accessibility consultant" },
{ quote: "We deleted half our custom CSS.", name: "Ava Thompson", role: "Founder" },
];
/*
* A row of wordmarks travelling left and a row of small quote cards travelling right, named underneath. Hover a
* row and it slows to a stop; Pause, at the top right, stops both the same way (a pause control for moving
* content).
*/
export default function MarqueeRows() {
const id = useId();
const [paused, setPaused] = useState(false);
return (
<div className="flex w-full min-w-0 flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-pause`} className="text-sm text-muted-foreground">
Pause
</label>
<Switch id={`${id}-pause`} size="sm" checked={paused} onCheckedChange={setPaused} />
</div>
<div className="flex flex-1 flex-col justify-center gap-6">
<div className="flex flex-col items-center gap-2">
{/* The items carry the space between them, so gap is 0 */}
<Marquee paused={paused} gap={0} className="w-full">
{logos.map((logo) => (
<span key={logo.name} className={cn("px-6 text-base text-muted-foreground", logo.style)}>
{logo.name}
</span>
))}
</Marquee>
<span className="text-xs text-muted-foreground">Logos</span>
</div>
<div className="flex flex-col items-center gap-2">
{/* Room above and below, so the cards' shadows aren't clipped */}
<Marquee paused={paused} direction="right" speed={30} className="w-full py-2">
{quotes.map((item) => (
<Card key={item.name} size="sm" className="w-64" asChild>
<figure>
<CardContent className="flex flex-col gap-3">
<blockquote className="text-sm">“{item.quote}”</blockquote>
<figcaption className="text-xs">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground"> · {item.role}</span>
</figcaption>
</CardContent>
</figure>
</Card>
))}
</Marquee>
<span className="text-xs text-muted-foreground">Cards, the other way</span>
</div>
</div>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { animate, useReducedMotion } from "motion/react"; import { type ComponentProps, type CSSProperties, type FocusEvent, type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; /* * Marquee: a row of logos or cards that scrolls on its own (owner's roadmap, 2026-10-16). * - Seamless: the children are repeated until they cover the row with one copy to spare, and the track slides * exactly one copy (and a gap) before starting over, which looks the same, at `speed` pixels a second. Only a * transform moves, on the Web Animations API, so the compositor runs it without React or the main thread. * - It rests where nobody's looking: paused off-screen (IntersectionObserver) and while the tab is hidden. * - The crafted part: hover (pauseOnHover) and keyboard focus inside slow it to a stop over duration.slow rather * than freezing it, and it picks up speed again when you leave, from exactly where it stopped. `paused` stops * it the same way, for a pause control (WCAG 2.2.2: moving content that lasts needs a way to stop it). * Only at full speed does the loop run by itself. While it slows or picks up it's paused and stepped by hand, * frame by frame: changing playbackRate every frame on an animation the compositor runs let the two clocks * drift, so leaving the hover snapped it to where it would have been without one (owner, 2026-10-21). * - The copies are aria-hidden and inert, so screen readers and Tab meet each item once. * - The edges fade through a static mask, so items arrive and leave softly on any surface. * - With reduced motion it doesn't move: one copy, in a row you can scroll. */ /** Each end fades over a tenth of the row */ const fades = { horizontal: "linear-gradient(to right, transparent, black 10%, black 90%, transparent)", vertical: "linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)", } as const; const subscribe = () => () => {}; /** The loop runs by itself only at full speed (`free`), and only while it can be seen: on screen, in a visible tab */ function syncRunning(loop: Animation | null, onScreen: boolean, free: boolean) { if (!loop) return; const run = free && onScreen && document.visibilityState !== "hidden"; if (run && loop.playState === "paused") loop.play(); else if (!run && loop.playState === "running") loop.pause(); } export type MarqueeProps = ComponentProps<"div"> & { /** Which way the items travel. Up and down need a height on the marquee (`className="h-72"`) */ direction?: "left" | "right" | "up" | "down"; /** Pixels a second */ speed?: number; /** Slow to a stop while the pointer is over it */ pauseOnHover?: boolean; /** Stopped (it slows to a stop, and picks up again when set back) */ paused?: boolean; /** Space between items, and between the copies: a CSS length, or pixels. gap-4 (1rem) if left out */ gap?: number | string; /** Fade the items out at the ends */ fade?: boolean; }; /** * A row of items that scrolls on its own, round and round. Put the items straight inside: logos, cards, quotes. */ function Marquee({ direction = "left", speed = 40, pauseOnHover = true, paused = false, gap, fade = true, className, style, children, ref, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: MarqueeProps) { const vertical = direction === "up" || direction === "down"; const reduce = useReducedMotion() ?? false; // Read only after hydration, so the server's HTML (always the moving row) is what hydrates const hydrated = useSyncExternalStore( subscribe, () => true, () => false, ); const still = hydrated && reduce; const root = useRef<HTMLDivElement | null>(null); const track = useRef<HTMLDivElement>(null); const loop = useRef<Animation | null>(null); // How far through a loop it was, kept when the loop is rebuilt (a resize, a new speed) const phase = useRef(0); const onScreen = useRef(true); const [copies, setCopies] = useState(2); /** One copy and one gap, in pixels: the distance of a loop */ const [length, setLength] = useState(0); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const held = paused || focused || (pauseOnHover && hovered); // The loop's speed, 0 to 1. It starts where the props say, so nothing glides on first paint. const rate = useRef(held ? 0 : 1); const setRef = useCallback( (node: HTMLDivElement | null) => { root.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }, [ref], ); // Measure a loop and how many copies cover the row, again whenever either resizes useLayoutEffect(() => { const node = root.current; const row = track.current; if (!node || !row || still) return; const measure = () => { const [first, second] = Array.from(row.children) as HTMLElement[]; if (!first || !second) return; // Right to left, the copies run leftwards, so the distance is the same either way const next = Math.abs( vertical ? second.offsetTop - first.offsetTop : second.offsetLeft - first.offsetLeft, ); const room = vertical ? node.clientHeight : node.clientWidth; setLength(next); setCopies(next > 0 ? Math.max(2, Math.ceil(room / next) + 1) : 2); }; measure(); const observer = new ResizeObserver(measure); observer.observe(node); if (row.firstElementChild) observer.observe(row.firstElementChild); return () => observer.disconnect(); }, [vertical, still]); // The loop: one copy's length, linear, forever useEffect(() => { const row = track.current; if (!row || still || length <= 0 || speed <= 0 || typeof row.animate !== "function") return; const axis = vertical ? "translateY" : "translateX"; // Right to left, the copies start at the right edge and run leftwards, so the loop sits one copy further // right: the first copy always comes back to its own place const shift = !vertical && getComputedStyle(row).direction === "rtl" ? length : 0; const ends = (direction === "left" || direction === "up" ? [0, -length] : [-length, 0]).map( (end) => end + shift, ); const ms = (length / speed) * 1000; const animation = row.animate( ends.map((end) => ({ transform: `${axis}(${end}px)` })), { duration: ms, iterations: Number.POSITIVE_INFINITY, easing: "linear" }, ); animation.currentTime = phase.current * ms; loop.current = animation; syncRunning(animation, onScreen.current, rate.current === 1); return () => { phase.current = ((Number(animation.currentTime) || 0) / ms) % 1; animation.cancel(); loop.current = null; }; }, [length, speed, direction, vertical, still]); // Rest off-screen and in a hidden tab useEffect(() => { const node = root.current; if (!node || still) return; const update = () => syncRunning(loop.current, onScreen.current, rate.current === 1); let observer: IntersectionObserver | undefined; if (typeof IntersectionObserver !== "undefined") { observer = new IntersectionObserver((entries) => { const entry = entries.at(-1); onScreen.current = entry?.isIntersecting ?? true; update(); }); observer.observe(node); } document.addEventListener("visibilitychange", update); return () => { observer?.disconnect(); document.removeEventListener("visibilitychange", update); }; }, [still]); // Hover, focus and `paused` glide the speed to 0 and back, rather than freezing it. Meanwhile the loop is // paused and its time stepped by hand, at the gliding speed, so it never leaves the place it's drawn at. useEffect(() => { const target = held ? 0 : 1; if (rate.current === target) return; let last = performance.now(); // Below full speed from here on: the loop stops running by itself if (rate.current === 1) rate.current = 1 - Number.EPSILON; syncRunning(loop.current, onScreen.current, false); const glide = animate(rate.current, target, { duration: reduce ? 0 : duration.slow, ease: ease.out, onUpdate: (value) => { const now = performance.now(); const animation = loop.current; if (animation && onScreen.current && document.visibilityState !== "hidden") animation.currentTime = (Number(animation.currentTime) || 0) + (now - last) * value; last = now; rate.current = value; }, onComplete: () => { rate.current = target; // Back to full speed: it runs by itself again, on from where it is syncRunning(loop.current, onScreen.current, rate.current === 1); }, }); return () => glide.stop(); }, [held, reduce]); const spacing: CSSProperties | undefined = gap === undefined ? undefined : { gap: typeof gap === "number" ? `${gap}px` : gap }; const groups = Array.from({ length: still ? 1 : copies }, (_, copy) => copy); const mask = fade && !still ? fades[vertical ? "vertical" : "horizontal"] : undefined; return ( // biome-ignore lint/a11y/noStaticElementInteractions: hover and focus only slow the loop down; nothing here is pressed <div ref={setRef} data-slot="marquee" data-direction={direction} data-orientation={vertical ? "vertical" : "horizontal"} data-paused={held || undefined} // With reduced motion it's a row to scroll, so the keyboard can reach it too tabIndex={still ? 0 : undefined} className={cn( "relative flex overflow-hidden", vertical && "flex-col", still && (vertical ? "overflow-y-auto" : "overflow-x-auto"), "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", className, )} style={mask ? { maskImage: mask, WebkitMaskImage: mask, ...style } : style} onPointerEnter={(event: PointerEvent<HTMLDivElement>) => { onPointerEnter?.(event); if (event.pointerType === "mouse") setHovered(true); }} onPointerLeave={(event: PointerEvent<HTMLDivElement>) => { onPointerLeave?.(event); setHovered(false); }} onFocus={(event: FocusEvent<HTMLDivElement>) => { onFocus?.(event); setFocused(true); }} onBlur={(event: FocusEvent<HTMLDivElement>) => { onBlur?.(event); if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }} {...props} > <div ref={track} data-slot="marquee-track" className={cn("flex shrink-0 gap-4", vertical ? "h-max flex-col" : "w-max")} style={spacing} > {groups.map((copy) => ( // The first copy is the content; the others are only there to fill the row <div key={copy} data-slot="marquee-group" aria-hidden={copy > 0 || undefined} inert={copy > 0 || undefined} className={cn("flex shrink-0 gap-4", vertical && "flex-col")} style={spacing} > {children} </div> ))} </div> </div> ); } export { Marquee };Update the import paths to match your project.
Examples
Vertical
Up and down need a height on the marquee. Two columns going opposite ways make a wall of quotes.
“Setup took an afternoon, not a sprint.”
“It looked right in our theme on the first try.”
“The motion is quiet. Nothing shows off.”
“Keyboard support was already there.”
“We deleted half our custom CSS.”
“Dark mode just worked.”
“Dark mode just worked.”
“We deleted half our custom CSS.”
“Keyboard support was already there.”
“The motion is quiet. Nothing shows off.”
“It looked right in our theme on the first try.”
“Setup took an afternoon, not a sprint.”
import { Card, CardContent } from "@/components/ui/card";
import { Marquee } from "@/components/ui/marquee";
const quotes = [
{ quote: "Setup took an afternoon, not a sprint.", name: "Maya Chen" },
{ quote: "It looked right in our theme on the first try.", name: "Tomás Rivera" },
{ quote: "The motion is quiet. Nothing shows off.", name: "Priya Nair" },
{ quote: "Keyboard support was already there.", name: "Jonas Berg" },
{ quote: "We deleted half our custom CSS.", name: "Ava Thompson" },
{ quote: "Dark mode just worked.", name: "Leo Park" },
];
function Quote({ quote, name }: { quote: string; name: string }) {
return (
<Card size="sm" asChild>
<figure>
<CardContent className="flex flex-col gap-3">
<blockquote className="text-sm">“{quote}”</blockquote>
<figcaption className="text-xs text-muted-foreground">{name}</figcaption>
</CardContent>
</figure>
</Card>
);
}
/*
* Two columns of quotes, one travelling up and one down. A vertical marquee needs a height; the ends fade
* top and bottom.
*/
export default function MarqueeVertical() {
return (
<div className="grid w-full max-w-md min-w-0 grid-cols-2 gap-4">
{/* Room at the sides, so the cards' shadows aren't clipped */}
<Marquee direction="up" speed={24} className="h-72 px-2">
{quotes.map((item) => (
<Quote key={item.name} {...item} />
))}
</Marquee>
<Marquee direction="down" speed={24} className="h-72 px-2">
{[...quotes].reverse().map((item) => (
<Quote key={item.name} {...item} />
))}
</Marquee>
</div>
);
}API reference
Put the items straight inside. The first copy is the content; the copies that fill the row are aria-hidden and inert, so screen readers and Tab meet each item once. It rests off-screen and in a hidden tab, and slows to a stop while it has keyboard focus. With reduced motion it doesn't move: one copy, in a row you can scroll. Also accepts every native div prop.
| Prop | Type | Default | Description |
|---|---|---|---|
direction? | "left" | "right" | "up" | "down" | "left" | Which way the items travel. Up and down need a height (className="h-72"). |
speed? | number | 40 | Pixels a second. |
pauseOnHover? | boolean | true | Slow to a stop while the pointer is over it, and pick up again on leaving. |
paused? | boolean | false | Stops it, slowing down the same way. Wire it to a pause control when the marquee runs beside other content. |
gap? | number | string | 16px (gap-4) | Space between items and between the copies: a CSS length, or a number of pixels. |
fade? | boolean | true | Fades the items out at both ends. |
Related components
- Team plan8 of 10 seats usedActive
A React card for Next.js and Tailwind CSS: a surface for grouped content with header, action, content and footer parts, a small size and a link style.
- Generating a reply…
Accessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.
- 12,480Users
An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.