Reverse Rows Marquee
Original · freetwo rows scrolling opposite
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/mqx-reverse-rows.json"use client";
import { useId, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { useReducedMotion } from "motion/react";
type Testimonial = {
quote: string;
name: string;
role: string;
rating: number;
};
type SpeedOption = {
id: string;
label: string;
multiplier: number;
};
const ROW_A: Testimonial[] = [
{
quote:
"We shipped the redesign in three weeks and watched signups climb 41% the month after. The team just knows how to keep momentum.",
name: "Priya Nandakumar",
role: "Head of Growth, Loomwork",
rating: 5,
},
{
quote:
"Support tickets asking 'where do I click' basically vanished. That was the clearest signal our navigation finally made sense.",
name: "Marcus Feld",
role: "Product Lead, Northbeam",
rating: 5,
},
{
quote:
"They rebuilt our onboarding and cut the drop-off between step two and three almost in half. The numbers held for a full quarter.",
name: "Elena Vasquez",
role: "VP Product, Cadence",
rating: 5,
},
{
quote:
"Fastest handoff I've had with any studio. Design tokens, docs, and a recorded walkthrough were waiting for me on day one.",
name: "Devon Park",
role: "Staff Engineer, Riverstone",
rating: 5,
},
{
quote:
"Our marketing site went from a 62 to a 98 on Lighthouse. Sales stopped apologizing for the load time on every demo call.",
name: "Aisha Rahman",
role: "CMO, Fielded",
rating: 5,
},
{
quote:
"The motion work is restrained in the best way. It guides the eye without ever getting in the way of the actual task.",
name: "Tomas Berg",
role: "Design Director, Halo Labs",
rating: 4,
},
];
const ROW_B: Testimonial[] = [
{
quote:
"Migrated 400 pages with zero broken links and a redirect map that actually made sense when I opened it six months later.",
name: "Owen Cassidy",
role: "Platform Engineer, Brightseed",
rating: 5,
},
{
quote:
"Accessibility went from an afterthought to a passing audit. Keyboard navigation works everywhere now, not just the happy path.",
name: "Naomi Oduya",
role: "Accessibility Consultant, Openfield",
rating: 5,
},
{
quote:
"The component library paid for itself by the second sprint. New features drop in without a full design review every time.",
name: "Rafael Mendes",
role: "Engineering Manager, Slate & Co",
rating: 5,
},
{
quote:
"They caught a layout-shift bug our own team had been chasing for a month, then fixed it in a single afternoon.",
name: "Hannah Sorensen",
role: "Senior Developer, Kestrel",
rating: 5,
},
{
quote:
"Conversion on the pricing page moved 18% after the copy and layout pass. A small change that turned into real revenue.",
name: "Julian Wei",
role: "Founder, Tidepool",
rating: 5,
},
{
quote:
"Clear communication, honest timelines, and no surprises on the invoice. Rarer than it should be, and deeply appreciated.",
name: "Bianca Rossi",
role: "Operations Director, Vantage",
rating: 4,
},
];
const SPEEDS: SpeedOption[] = [
{ id: "slow", label: "0.5×", multiplier: 1.9 },
{ id: "normal", label: "1×", multiplier: 1 },
{ id: "fast", label: "1.5×", multiplier: 0.6 },
];
function initialsOf(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function StarRating({ rating }: { rating: number }) {
return (
<span
className="inline-flex items-center gap-0.5"
role="img"
aria-label={`${rating} out of 5 stars`}
>
{[0, 1, 2, 3, 4].map((i) => (
<svg
key={i}
viewBox="0 0 20 20"
className={
i < rating
? "h-3.5 w-3.5 fill-amber-400"
: "h-3.5 w-3.5 fill-slate-300 dark:fill-slate-700"
}
aria-hidden="true"
>
<path d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.94 2.6.94-5.5-4-3.9 5.53-.8z" />
</svg>
))}
</span>
);
}
function QuoteGlyph() {
return (
<svg
viewBox="0 0 32 32"
className="h-7 w-7 text-indigo-400 dark:text-indigo-500"
aria-hidden="true"
fill="currentColor"
>
<path d="M12.5 8c-3.6 1.5-6.2 4.9-6.2 9.3 0 3.3 2 5.7 4.9 5.7 2.5 0 4.3-1.9 4.3-4.3 0-2.4-1.7-4.1-3.9-4.1-.4 0-1 .1-1.1.1.4-1.9 2.2-4.1 4.4-5.2L12.5 8zm11.6 0c-3.6 1.5-6.2 4.9-6.2 9.3 0 3.3 2 5.7 4.9 5.7 2.5 0 4.3-1.9 4.3-4.3 0-2.4-1.7-4.1-3.9-4.1-.4 0-1 .1-1.1.1.4-1.9 2.2-4.1 4.4-5.2L24.1 8z" />
</svg>
);
}
function Card({ t, duplicate }: { t: Testimonial; duplicate?: boolean }) {
return (
<li
aria-hidden={duplicate || undefined}
className="mr-5 w-[300px] shrink-0 sm:mr-6 sm:w-[380px]"
>
<figure className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between">
<QuoteGlyph />
<StarRating rating={t.rating} />
</div>
<blockquote className="mt-4 flex-1 text-[15px] leading-relaxed text-slate-700 dark:text-slate-200">
{t.quote}
</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span
aria-hidden="true"
className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-xs font-semibold text-white"
>
{initialsOf(t.name)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-slate-900 dark:text-white">
{t.name}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">
{t.role}
</span>
</span>
</figcaption>
</figure>
</li>
);
}
const EDGE_MASK: CSSProperties = {
WebkitMaskImage:
"linear-gradient(to right, transparent, #000 7%, #000 93%, transparent)",
maskImage:
"linear-gradient(to right, transparent, #000 7%, #000 93%, transparent)",
};
export default function MqxReverseRows() {
const reduced = useReducedMotion();
const [isPlaying, setIsPlaying] = useState(true);
const [swapped, setSwapped] = useState(false);
const [speedIndex, setSpeedIndex] = useState(1);
const [hovered, setHovered] = useState<"a" | "b" | null>(null);
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
const groupId = useId();
const speed = SPEEDS[speedIndex];
const running = isPlaying && !reduced;
const rowStyle = (base: number, dir: "left" | "right"): CSSProperties => ({
animationName: dir === "left" ? "mqxRR-scroll-left" : "mqxRR-scroll-right",
animationDuration: `${(base * speed.multiplier).toFixed(2)}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
});
const dirA: "left" | "right" = swapped ? "right" : "left";
const dirB: "left" | "right" = swapped ? "left" : "right";
const stateA = running && hovered !== "a" ? "running" : "paused";
const stateB = running && hovered !== "b" ? "running" : "paused";
const onSpeedKeys = (e: KeyboardEvent<HTMLDivElement>) => {
const last = SPEEDS.length - 1;
let next = speedIndex;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = speedIndex === last ? 0 : speedIndex + 1;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = speedIndex === 0 ? last : speedIndex - 1;
} else if (e.key === "Home") {
next = 0;
} else if (e.key === "End") {
next = last;
} else {
return;
}
e.preventDefault();
setSpeedIndex(next);
radioRefs.current[next]?.focus();
};
const ringBase =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950";
return (
<section className="relative w-full overflow-hidden bg-slate-50 py-20 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes mqxRR-scroll-left {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-50%, 0, 0); }
}
@keyframes mqxRR-scroll-right {
from { transform: translate3d(-50%, 0, 0); }
to { transform: translate3d(0, 0, 0); }
}
@media (prefers-reduced-motion: reduce) {
.mqxRR-track { animation: none !important; transform: none !important; }
}
`}</style>
<div className="mx-auto mb-12 max-w-6xl px-4 sm:mb-14 sm:px-6">
<div className="flex flex-col items-start gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Wall of love
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
What teams say after we ship
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Real words from the people who launched with us — two rows
running in opposite directions so every voice gets its moment.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setIsPlaying((p) => !p)}
aria-label={isPlaying ? "Pause the marquee" : "Play the marquee"}
className={`inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 ${ringBase}`}
>
{isPlaying ? (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<rect x="5" y="4" width="3.5" height="12" rx="1" />
<rect x="11.5" y="4" width="3.5" height="12" rx="1" />
</svg>
) : (
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6 4.5l9 5.5-9 5.5z" />
</svg>
)}
{isPlaying ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={() => setSwapped((s) => !s)}
aria-pressed={swapped}
className={`inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-500 ${ringBase}`}
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M4 7h10l-2.5-2.5M16 13H6l2.5 2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Swap direction
</button>
<div
role="radiogroup"
aria-label="Scroll speed"
onKeyDown={onSpeedKeys}
className="inline-flex items-center rounded-full bg-slate-200/70 p-1 dark:bg-slate-800/70"
>
{SPEEDS.map((opt, i) => {
const active = i === speedIndex;
return (
<button
key={opt.id}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
id={`${groupId}-${opt.id}`}
onClick={() => setSpeedIndex(i)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition-colors ${ringBase} ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{opt.label}
</button>
);
})}
</div>
</div>
</div>
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
Hover a row to pause it. Motion respects your reduced-motion setting.
</p>
<span className="sr-only" role="status" aria-live="polite">
{`Marquee ${isPlaying ? "playing" : "paused"} at ${speed.label} speed, ${
swapped ? "directions swapped" : "top row moving left"
}.`}
</span>
</div>
<div className="flex flex-col gap-5 sm:gap-6" style={EDGE_MASK}>
<div
role="group"
aria-label="Customer testimonials, first row"
className="overflow-hidden"
onMouseEnter={() => setHovered("a")}
onMouseLeave={() => setHovered((h) => (h === "a" ? null : h))}
>
<ul
className="mqxRR-track flex w-max list-none will-change-transform"
style={{ ...rowStyle(46, dirA), animationPlayState: stateA }}
>
{ROW_A.map((t, i) => (
<Card key={`a-${i}`} t={t} />
))}
{ROW_A.map((t, i) => (
<Card key={`a-dup-${i}`} t={t} duplicate />
))}
</ul>
</div>
<div
role="group"
aria-label="Customer testimonials, second row"
className="overflow-hidden"
onMouseEnter={() => setHovered("b")}
onMouseLeave={() => setHovered((h) => (h === "b" ? null : h))}
>
<ul
className="mqxRR-track flex w-max list-none will-change-transform"
style={{ ...rowStyle(54, dirB), animationPlayState: stateB }}
>
{ROW_B.map((t, i) => (
<Card key={`b-${i}`} t={t} />
))}
{ROW_B.map((t, i) => (
<Card key={`b-dup-${i}`} t={t} duplicate />
))}
</ul>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Glowing Logo Marquee
OriginalA dual-row infinite logo marquee whose rows scroll in opposite directions, pause on hover and lift each logo with a shimmer sweep over an animated gradient glow.

Vertical Testimonial Marquee
OriginalThree vertical testimonial columns scroll at different speeds and opposite directions, pausing on hover with a shine sweep so reviews loop endlessly beside a sticky heading.

Velocity Band Marquee
OriginalA skewed dual-direction headline marquee of big gradient and outlined words plus a pill ticker, with speed and pause controls and pause on hover over an animated mesh background.

Logos Marquee
Originallogo marquee (generic marks)

Text Marquee
Originalscrolling text marquee

Cards Marquee
Originalcard marquee

Vertical Marquee
Originalvertical marquee columns

Gradient Fade Marquee
Originalmarquee with edge fade

Tilted Marquee
Originaltilted marquee band

Image Marquee
Originalimage marquee

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

