Vertical Marquee
Original · freevertical marquee columns
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-vertical.json"use client";
import {
useCallback,
useId,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";
type SpeedId = "slow" | "normal" | "fast";
type Speed = { id: SpeedId; label: string; mult: number };
type Testimonial = {
id: string;
quote: string;
name: string;
role: string;
company: string;
rating: number;
tone: string;
};
const SPEEDS: readonly Speed[] = [
{ id: "slow", label: "Slow", mult: 1.7 },
{ id: "normal", label: "Normal", mult: 1 },
{ id: "fast", label: "Fast", mult: 0.55 },
] as const;
const AVATAR_TONES = [
"bg-indigo-500/12 text-indigo-700 ring-indigo-500/25 dark:bg-indigo-400/15 dark:text-indigo-300",
"bg-emerald-500/12 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-400/15 dark:text-emerald-300",
"bg-violet-500/12 text-violet-700 ring-violet-500/25 dark:bg-violet-400/15 dark:text-violet-300",
"bg-amber-500/12 text-amber-700 ring-amber-500/25 dark:bg-amber-400/15 dark:text-amber-300",
"bg-sky-500/12 text-sky-700 ring-sky-500/25 dark:bg-sky-400/15 dark:text-sky-300",
"bg-rose-500/12 text-rose-700 ring-rose-500/25 dark:bg-rose-400/15 dark:text-rose-300",
] as const;
const COLUMN_A: Testimonial[] = [
{
id: "priya",
quote:
"We cut on-call pages by 63% in the first month. It finally tells the difference between a deploy blip and a real incident.",
name: "Priya Nair",
role: "Staff SRE",
company: "Northwind Labs",
rating: 5,
tone: AVATAR_TONES[0],
},
{
id: "diego",
quote:
"Traced a p99 regression to a single N+1 query in about nine minutes. That used to eat a whole afternoon.",
name: "Diego Mendes",
role: "Backend Lead",
company: "Cartogram",
rating: 5,
tone: AVATAR_TONES[1],
},
{
id: "hannah",
quote:
"The rollout diff view is the feature I didn't know I needed. Every deploy ships with a before/after I can actually read.",
name: "Hannah Osei",
role: "Platform Engineer",
company: "Fielded",
rating: 4,
tone: AVATAR_TONES[2],
},
{
id: "sam",
quote:
"Set up alerting on a Sunday, muted the noise by Tuesday. My phone stopped buzzing at 3am for things that don't matter.",
name: "Sam Whitlock",
role: "DevOps",
company: "Junegrid",
rating: 5,
tone: AVATAR_TONES[3],
},
];
const COLUMN_B: Testimonial[] = [
{
id: "aisha",
quote:
"Onboarding took one afternoon. By Friday the whole team was reviewing the same dashboards instead of arguing over Slack screenshots.",
name: "Aisha Rahman",
role: "Engineering Manager",
company: "Postmark Row",
rating: 5,
tone: AVATAR_TONES[4],
},
{
id: "lucas",
quote:
"Cardinality that doesn't bankrupt us. We kept every label we cared about and the monthly bill still went down.",
name: "Lucas Byrne",
role: "SRE",
company: "Halcyon",
rating: 5,
tone: AVATAR_TONES[5],
},
{
id: "mei",
quote:
"The query language reads like a sentence. I onboarded a junior in a day, not a quarter, and she was writing alerts by lunch.",
name: "Mei Lin Chen",
role: "Infra Lead",
company: "Brightwater",
rating: 4,
tone: AVATAR_TONES[0],
},
{
id: "tomas",
quote:
"We caught a memory leak in staging before it ever touched prod. That's the whole pitch, and it actually delivered.",
name: "Tomás Herrera",
role: "Staff Engineer",
company: "Ledgerly",
rating: 5,
tone: AVATAR_TONES[1],
},
];
const COLUMN_C: Testimonial[] = [
{
id: "nadia",
quote:
"Two vendors collapsed into one. Logs, metrics, and traces finally share a timeline I trust during an incident.",
name: "Nadia Kovač",
role: "Head of Platform",
company: "Sparrow",
rating: 5,
tone: AVATAR_TONES[2],
},
{
id: "ben",
quote:
"Incident review went from a wiki nobody read to a link everyone opens. The auto-generated timeline does the boring part.",
name: "Ben Adeyemi",
role: "Reliability Engineer",
company: "Tidewater",
rating: 5,
tone: AVATAR_TONES[3],
},
{
id: "sofia",
quote:
"I'm not an ops person and I still ship with confidence. The defaults are sane and the guardrails are real, not decorative.",
name: "Sofia Marchetti",
role: "Full-stack Engineer",
company: "Quillpad",
rating: 4,
tone: AVATAR_TONES[4],
},
{
id: "ryan",
quote:
"MTTR down 40% two quarters running. I put the number in the board deck and nobody questioned it.",
name: "Ryan O'Neill",
role: "CTO",
company: "Meadowlark",
rating: 5,
tone: AVATAR_TONES[5],
},
];
const COLUMNS: { items: Testimonial[]; dir: "up" | "down"; base: number }[] = [
{ items: COLUMN_A, dir: "up", base: 42 },
{ items: COLUMN_B, dir: "down", base: 52 },
{ items: COLUMN_C, dir: "up", base: 46 },
];
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
const first = parts[0]?.[0] ?? "";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase();
}
function StarRow({ 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 text-amber-400"
: "h-3.5 w-3.5 fill-zinc-300 text-zinc-300 dark:fill-zinc-700 dark:text-zinc-700"
}
aria-hidden="true"
>
<path d="M10 1.5l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.1l-4.95 2.6.94-5.5-4-3.9 5.53-.8L10 1.5z" />
</svg>
))}
</span>
);
}
function Card({ t }: { t: Testimonial }) {
return (
<figure className="group/card relative rounded-2xl border border-zinc-200/80 bg-white/90 p-5 shadow-sm backdrop-blur-sm transition duration-300 hover:-translate-y-0.5 hover:border-indigo-300/70 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/70 dark:hover:border-indigo-500/40">
<div className="flex items-start justify-between gap-3">
<StarRow rating={t.rating} />
<svg
viewBox="0 0 24 24"
className="h-6 w-6 shrink-0 fill-indigo-500/15 text-indigo-500/15 transition-colors group-hover/card:fill-indigo-500/30 dark:fill-indigo-400/15 dark:group-hover/card:fill-indigo-400/25"
aria-hidden="true"
>
<path d="M9.6 6C6.5 7.4 5 9.9 5 13.5V18h5.5v-5.5H8c0-2 .9-3.3 2.8-4.1L9.6 6zm9 0C15.5 7.4 14 9.9 14 13.5V18h5.5v-5.5H17c0-2 .9-3.3 2.8-4.1L18.6 6z" />
</svg>
</div>
<blockquote className="mt-3 text-[0.95rem] leading-relaxed text-zinc-700 dark:text-zinc-200">
{t.quote}
</blockquote>
<figcaption className="mt-4 flex items-center gap-3 border-t border-zinc-100 pt-4 dark:border-zinc-800/80">
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-semibold ring-1 ${t.tone}`}
aria-hidden="true"
>
{initials(t.name)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{t.name}
</span>
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
{t.role} · {t.company}
</span>
</span>
</figcaption>
</figure>
);
}
export default function MqxVertical() {
const prefersReduced = useReducedMotion();
const staticMode = prefersReduced === true;
const [playing, setPlaying] = useState(true);
const [speed, setSpeed] = useState<SpeedId>("normal");
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
const mult = SPEEDS.find((s) => s.id === speed)?.mult ?? 1;
const onRadioKeyDown = useCallback(
(e: KeyboardEvent<HTMLButtonElement>) => {
const cur = SPEEDS.findIndex((s) => s.id === speed);
let next = cur;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = (cur + 1) % SPEEDS.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (cur - 1 + SPEEDS.length) % SPEEDS.length;
break;
case "Home":
next = 0;
break;
case "End":
next = SPEEDS.length - 1;
break;
default:
return;
}
e.preventDefault();
setSpeed(SPEEDS[next].id);
radioRefs.current[next]?.focus();
},
[speed],
);
const regionId = useId();
const rootPlaying = playing && !staticMode;
return (
<section
className="mqxv-root relative w-full overflow-hidden bg-slate-50 py-20 dark:bg-zinc-950 sm:py-28"
aria-labelledby={`${regionId}-title`}
data-mqxv-playing={rootPlaying ? "true" : "false"}
>
<style>{`
@keyframes mqxv-scroll {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(0, -50%, 0); }
}
@keyframes mqxv-ping {
0% { transform: scale(1); opacity: 0.7; }
75%, 100% { transform: scale(2.2); opacity: 0; }
}
.mqxv-track {
animation: mqxv-scroll var(--mqxv-dur, 44s) linear infinite;
will-change: transform;
}
.mqxv-track[data-dir="down"] { animation-direction: reverse; }
.mqxv-root[data-mqxv-playing="false"] .mqxv-track { animation-play-state: paused; }
.mqxv-col:hover .mqxv-track,
.mqxv-col:focus-within .mqxv-track { animation-play-state: paused; }
.mqxv-fade {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
}
.mqxv-dot { animation: mqxv-ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite; }
@media (prefers-reduced-motion: reduce) {
.mqxv-track { animation: none !important; }
.mqxv-dot { animation: none !important; }
}
`}</style>
{/* atmospheric backdrop */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60%_50%_at_50%_-5%,rgba(99,102,241,0.14),transparent_70%)] dark:opacity-100 dark:[background:radial-gradient(60%_50%_at_50%_-5%,rgba(99,102,241,0.18),transparent_70%)]"
/>
<div className="relative mx-auto w-full max-w-6xl px-6">
{/* header + controls */}
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/70 px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-400">
<span className="relative flex h-2 w-2">
<span className="mqxv-dot absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
Verified reviews · updated weekly
</span>
<h2
id={`${regionId}-title`}
className="mt-4 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 sm:text-4xl"
>
Signal, not noise — straight from the on-call rotation
</h2>
<p className="mt-3 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Real teams, real incidents. Here's what changed once the alerts
started telling the truth.
</p>
</div>
{/* controls */}
<div className="flex shrink-0 flex-wrap items-center gap-3">
{staticMode ? (
<p className="max-w-[15rem] text-sm text-zinc-500 dark:text-zinc-400">
Motion paused to match your system's reduced-motion setting.
Scroll each column to read more.
</p>
) : (
<>
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={!playing}
aria-label={playing ? "Pause scrolling reviews" : "Play scrolling reviews"}
className="inline-flex items-center gap-2 rounded-full border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:border-indigo-400 hover:text-indigo-700 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:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-950"
>
{playing ? (
<svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" 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-current" aria-hidden="true">
<path d="M6 4.5l9 5.5-9 5.5V4.5z" />
</svg>
)}
{playing ? "Pause" : "Play"}
</button>
<div
role="radiogroup"
aria-label="Scroll speed"
className="inline-flex items-center rounded-full border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
>
{SPEEDS.map((s, i) => {
const active = s.id === speed;
return (
<button
key={s.id}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
ref={(el) => {
radioRefs.current[i] = el;
}}
onClick={() => setSpeed(s.id)}
onKeyDown={onRadioKeyDown}
className={`rounded-full px-3 py-1 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
active
? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{s.label}
</button>
);
})}
</div>
</>
)}
</div>
</div>
{/* marquee */}
<div
className="relative mt-12 grid h-[560px] grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-5 lg:grid-cols-3"
role="region"
aria-label="Customer testimonials"
>
{COLUMNS.map((col, ci) => {
const dur = (col.base * mult).toFixed(1);
const colStyle = { ["--mqxv-dur"]: `${dur}s` } as CSSProperties;
const hideClass = ci === 1 ? "hidden sm:block" : ci === 2 ? "hidden lg:block" : "";
return (
<div
key={ci}
className={`mqxv-col mqxv-fade relative h-full overflow-hidden ${hideClass}`}
>
{staticMode ? (
<ul className="flex h-full flex-col gap-4 overflow-y-auto pr-1">
{col.items.map((t) => (
<li key={t.id}>
<Card t={t} />
</li>
))}
</ul>
) : (
<ul className="mqxv-track flex flex-col" data-dir={col.dir} style={colStyle}>
{[0, 1].map((dup) =>
col.items.map((t) => (
<li key={`${t.id}-${dup}`} aria-hidden={dup === 1} className="pb-4 sm:pb-5">
<Card t={t} />
</li>
)),
)}
</ul>
)}
</div>
);
})}
</div>
{!staticMode && (
<p className="mt-6 text-center text-xs text-zinc-400 dark:text-zinc-500">
Hover a column to pause it — or use the controls above.
</p>
)}
</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

Gradient Fade Marquee
Originalmarquee with edge fade

Reverse Rows Marquee
Originaltwo rows scrolling opposite

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.

