Video Testimonial
Original · freevideo testimonial cards
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/tstx-video.json"use client";
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Category = "startups" | "enterprise" | "agencies";
type Filter = "all" | Category;
interface Testimonial {
id: string;
name: string;
role: string;
company: string;
category: Category;
durationSec: number;
quote: string;
metric: { value: string; label: string };
gradient: string;
ring: string;
}
const FILTERS: ReadonlyArray<{ id: Filter; label: string }> = [
{ id: "all", label: "All stories" },
{ id: "startups", label: "Startups" },
{ id: "enterprise", label: "Enterprise" },
{ id: "agencies", label: "Agencies" },
];
const CATEGORY_LABEL: Record<Category, string> = {
startups: "Startup",
enterprise: "Enterprise",
agencies: "Agency",
};
const TESTIMONIALS: ReadonlyArray<Testimonial> = [
{
id: "t1",
name: "Maya Chen",
role: "Head of Growth",
company: "Brightline Labs",
category: "startups",
durationSec: 84,
quote:
"We plugged Cadence into our onboarding on a Friday and had our first automated cohort live by Monday. Time-to-value went from three weeks to four days, and support tickets fell off a cliff.",
metric: { value: "-78%", label: "time to first value" },
gradient: "from-indigo-500 via-violet-500 to-sky-500",
ring: "ring-indigo-400/40",
},
{
id: "t2",
name: "David Okafor",
role: "VP of Operations",
company: "Northgate Systems",
category: "enterprise",
durationSec: 132,
quote:
"Rolling software out to 3,000 people usually means a six-month change-management slog. With Cadence we hit 90% adoption in the first quarter without a single mandatory training session.",
metric: { value: "90%", label: "adoption in Q1" },
gradient: "from-sky-500 via-indigo-500 to-violet-500",
ring: "ring-sky-400/40",
},
{
id: "t3",
name: "Priya Nair",
role: "Founder",
company: "Studio Kelp",
category: "agencies",
durationSec: 97,
quote:
"As an agency we juggle a dozen client workspaces at once. Cadence is the first tool that didn't buckle under that mess. We took on four new retainers without adding headcount.",
metric: { value: "+4", label: "retainers, same team" },
gradient: "from-emerald-500 via-sky-500 to-indigo-500",
ring: "ring-emerald-400/40",
},
{
id: "t4",
name: "Lucas Moreau",
role: "CTO",
company: "Fernpath",
category: "startups",
durationSec: 108,
quote:
"The API is what sold my engineers. We replaced three brittle internal scripts with one Cadence workflow and reclaimed a full sprint of maintenance every single month.",
metric: { value: "40 hrs", label: "saved per month" },
gradient: "from-violet-500 via-indigo-500 to-sky-500",
ring: "ring-violet-400/40",
},
{
id: "t5",
name: "Hannah Weiss",
role: "Director of RevOps",
company: "Vantera",
category: "enterprise",
durationSec: 141,
quote:
"Our forecasts used to live in seven spreadsheets and one very anxious analyst. Now the numbers reconcile themselves nightly, and I walk into every board meeting with data I actually trust.",
metric: { value: "7 → 1", label: "tools consolidated" },
gradient: "from-amber-500 via-rose-500 to-violet-500",
ring: "ring-rose-400/40",
},
{
id: "t6",
name: "Theo Alvarez",
role: "Creative Director",
company: "Halcyon Co.",
category: "agencies",
durationSec: 76,
quote:
"I was skeptical an ops tool could feel this good to use. My team opens it on purpose. Project throughput is up by a third and nobody is begging for the old system back.",
metric: { value: "+33%", label: "project throughput" },
gradient: "from-rose-500 via-amber-500 to-emerald-500",
ring: "ring-amber-400/40",
},
];
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
const STYLES = `
@keyframes tstxv-ring{0%{transform:scale(1);opacity:.5}70%{transform:scale(2);opacity:0}100%{transform:scale(2);opacity:0}}
@keyframes tstxv-eq{0%,100%{transform:scaleY(.3)}50%{transform:scaleY(1)}}
@keyframes tstxv-blob{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(6%,-9%,0) scale(1.18)}}
.tstxv-ring{animation:tstxv-ring 2.6s cubic-bezier(.4,0,.2,1) infinite}
.tstxv-bar{transform-origin:bottom;animation:tstxv-eq .9s ease-in-out infinite}
.tstxv-blob{animation:tstxv-blob 15s ease-in-out infinite}
@media (prefers-reduced-motion: reduce){
.tstxv-ring,.tstxv-bar,.tstxv-blob{animation:none!important}
}
`;
function fmt(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, "0")}`;
}
function PlayIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M8 5.14v13.72a1 1 0 0 0 1.53.85l10.79-6.86a1 1 0 0 0 0-1.7L9.53 4.29A1 1 0 0 0 8 5.14Z" />
</svg>
);
}
function PauseIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M8 4.5A1.5 1.5 0 0 0 6.5 6v12a1.5 1.5 0 0 0 3 0V6A1.5 1.5 0 0 0 8 4.5Zm8 0A1.5 1.5 0 0 0 14.5 6v12a1.5 1.5 0 0 0 3 0V6A1.5 1.5 0 0 0 16 4.5Z" />
</svg>
);
}
function VolumeIcon({ muted, className }: { muted: boolean; className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={className}>
<path d="M11 5 6 9H3v6h3l5 4V5Z" fill="currentColor" stroke="none" />
{muted ? (
<path d="m16 9 5 6m0-6-5 6" />
) : (
<>
<path d="M16 8.5a5 5 0 0 1 0 7" />
<path d="M18.5 6a8.5 8.5 0 0 1 0 12" />
</>
)}
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className={className}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
}
function QuoteMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M9.5 5C6.46 6 4.5 8.9 4.5 12.4V19h6.6v-6.7H7.9c.05-2.2 1.06-3.6 3.2-4.2L9.5 5Zm9 0c-3.04 1-5 3.9-5 7.4V19h6.6v-6.7h-3.2c.05-2.2 1.06-3.6 3.2-4.2L18.5 5Z" />
</svg>
);
}
function StarIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="m12 3 2.6 5.3 5.9.86-4.25 4.14 1 5.86L12 16.9l-5.25 2.26 1-5.86L3.5 9.16l5.9-.86L12 3Z" />
</svg>
);
}
function Poster({
t,
overlay,
playing,
}: {
t: Testimonial;
overlay?: boolean;
playing?: boolean;
}) {
const initials = t.name
.split(" ")
.map((p) => p[0])
.join("");
return (
<>
<div className={`absolute inset-0 bg-gradient-to-br ${t.gradient}`} />
<div className="tstxv-blob absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/25 blur-2xl" />
<div className="absolute inset-0 opacity-[0.14] [background-image:radial-gradient(circle_at_1px_1px,#fff_1px,transparent_0)] [background-size:14px_14px]" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent" />
<span
aria-hidden="true"
className="absolute right-4 top-3 select-none text-7xl font-black leading-none text-white/15"
>
{initials}
</span>
{overlay ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
className={`${playing ? "" : "tstxv-ring"} absolute h-20 w-20 rounded-full bg-white/40`}
/>
<span className="relative flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-slate-900 shadow-xl">
{playing ? <PauseIcon className="h-6 w-6" /> : <PlayIcon className="ml-0.5 h-6 w-6" />}
</span>
</span>
) : null}
</>
);
}
export default function TstxVideo() {
const reduce = useReducedMotion();
const [filter, setFilter] = useState<Filter>("all");
const [activeId, setActiveId] = useState<string | null>(null);
const [playing, setPlaying] = useState(false);
const [muted, setMuted] = useState(false);
const [progress, setProgress] = useState(0);
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
const dialogRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const active = TESTIMONIALS.find((t) => t.id === activeId) ?? null;
const durationSec = active?.durationSec ?? 1;
const shown =
filter === "all"
? TESTIMONIALS
: TESTIMONIALS.filter((t) => t.category === filter);
useEffect(() => {
setProgress(0);
setMuted(false);
setPlaying(activeId != null);
}, [activeId]);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => {
setProgress((p) => Math.min(100, p + 100 / (durationSec * 10)));
}, 100);
return () => window.clearInterval(id);
}, [playing, durationSec]);
useEffect(() => {
if (progress >= 100) setPlaying(false);
}, [progress]);
useEffect(() => {
if (!activeId) return;
restoreRef.current = document.activeElement as HTMLElement | null;
const t = window.setTimeout(() => closeRef.current?.focus(), 0);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.clearTimeout(t);
document.body.style.overflow = prevOverflow;
restoreRef.current?.focus();
};
}, [activeId]);
function close() {
setActiveId(null);
}
function togglePlay() {
setPlaying((p) => {
if (!p && progress >= 100) setProgress(0);
return !p;
});
}
function onSeek(e: ChangeEvent<HTMLInputElement>) {
setProgress(Number(e.target.value));
}
function onDialogKey(e: KeyboardEvent<HTMLDivElement>) {
if (e.key === "Escape") {
e.stopPropagation();
close();
return;
}
if (e.key !== "Tab") return;
const nodes = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE);
if (!nodes || nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
let next = index;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % FILTERS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
next = (index - 1 + FILTERS.length) % FILTERS.length;
else return;
e.preventDefault();
setFilter(FILTERS[next].id);
radioRefs.current[next]?.focus();
}
const currentTime = (progress / 100) * durationSec;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-slate-900 sm:py-32 dark:bg-slate-950 dark:text-slate-100">
<style>{STYLES}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70 [background:radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.16),transparent_70%)] dark:opacity-100 dark:[background:radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.22),transparent_70%)]"
/>
<div className="relative mx-auto max-w-6xl">
<motion.div
initial={reduce ? false : { opacity: 0, y: 24 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: reduce ? 0 : 0.6, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto max-w-2xl text-center"
>
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 shadow-sm dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Customer stories
</span>
<h2 className="mt-6 text-balance text-4xl font-bold tracking-tight sm:text-5xl">
Don't take our word for it. Take theirs.
</h2>
<p className="mt-4 text-pretty text-lg leading-relaxed text-slate-600 dark:text-slate-400">
Ninety-second stories from the growth leads, operators, and founders who
run their entire day on Cadence.
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1.5 font-medium text-slate-700 dark:text-slate-200">
<span className="flex text-amber-500">
{Array.from({ length: 5 }).map((_, i) => (
<StarIcon key={i} className="h-4 w-4" />
))}
</span>
4.9 / 5 average
</span>
<span className="hidden h-4 w-px bg-slate-300 sm:block dark:bg-slate-700" />
<span>2,400+ teams</span>
<span className="hidden h-4 w-px bg-slate-300 sm:block dark:bg-slate-700" />
<span>12 industries</span>
</div>
</motion.div>
<div
role="radiogroup"
aria-label="Filter testimonials by team type"
className="mt-10 flex flex-wrap items-center justify-center gap-2"
>
{FILTERS.map((f, i) => {
const selected = filter === f.id;
return (
<button
key={f.id}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setFilter(f.id)}
onKeyDown={(e) => onRadioKey(e, i)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition 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 ${
selected
? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
: "border border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{f.label}
</button>
);
})}
</div>
<motion.ul
layout={!reduce}
className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
<AnimatePresence mode="popLayout">
{shown.map((t) => (
<motion.li
key={t.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94 }}
transition={{ duration: reduce ? 0 : 0.4, ease: [0.22, 1, 0.36, 1] }}
className="group flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition hover:shadow-lg hover:shadow-indigo-500/5 dark:border-slate-800 dark:bg-slate-900"
>
<button
type="button"
onClick={() => setActiveId(t.id)}
aria-label={`Play ${fmt(t.durationSec)} video testimonial from ${t.name}, ${t.role} at ${t.company}`}
className={`relative block aspect-video w-full overflow-hidden text-left ring-inset focus-visible:outline-none focus-visible:ring-2 ${t.ring} focus-visible:ring-2 focus-visible:ring-indigo-400`}
>
<Poster t={t} overlay />
<span className="absolute left-3 top-3 rounded-full bg-slate-950/55 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-white backdrop-blur-sm">
{CATEGORY_LABEL[t.category]}
</span>
<span className="absolute right-3 top-3 rounded-full bg-slate-950/55 px-2 py-1 text-[11px] font-semibold tabular-nums text-white backdrop-blur-sm">
{fmt(t.durationSec)}
</span>
<span className="absolute inset-x-4 bottom-3">
<span className="block text-base font-semibold text-white drop-shadow">
{t.name}
</span>
<span className="block text-xs text-white/80 drop-shadow">
{t.role}, {t.company}
</span>
</span>
</button>
<div className="flex flex-1 flex-col p-5">
<QuoteMark className="h-5 w-5 text-indigo-400/70" />
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{t.quote}
</p>
<div className="mt-4 flex items-center justify-between border-t border-slate-100 pt-4 dark:border-slate-800">
<span className="flex items-baseline gap-1.5">
<span className="text-lg font-bold tracking-tight text-slate-900 dark:text-white">
{t.metric.value}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{t.metric.label}
</span>
</span>
<span className="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 transition group-hover:gap-1.5 dark:text-indigo-400">
Watch
<PlayIcon className="h-3 w-3" />
</span>
</div>
</div>
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</div>
<AnimatePresence>
{active ? (
<motion.div
key="backdrop"
initial={reduce ? { opacity: 1 } : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
onMouseDown={(e) => {
if (e.target === e.currentTarget) close();
}}
onKeyDown={onDialogKey}
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="tstxv-dialog-name"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: reduce ? 0 : 0.28, ease: [0.22, 1, 0.36, 1] }}
className="relative w-full max-w-3xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<button
ref={closeRef}
type="button"
onClick={close}
aria-label="Close video"
className="absolute right-3 top-3 z-10 inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-950/50 text-white transition hover:bg-slate-950/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<CloseIcon className="h-5 w-5" />
</button>
<div className="relative aspect-video w-full overflow-hidden">
<Poster t={active} />
<button
type="button"
onClick={togglePlay}
aria-label={playing ? "Pause video" : "Play video"}
className="absolute inset-0 flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-slate-900 shadow-xl transition group-hover:scale-105">
{playing ? (
<PauseIcon className="h-7 w-7" />
) : (
<PlayIcon className="ml-0.5 h-7 w-7" />
)}
</span>
</button>
<div className="absolute inset-x-4 bottom-14 flex items-end justify-between">
<div>
<p id="tstxv-dialog-name" className="text-lg font-semibold text-white drop-shadow">
{active.name}
</p>
<p className="text-sm text-white/80 drop-shadow">
{active.role} · {active.company}
</p>
</div>
{playing && !muted ? (
<span className="flex h-6 items-end gap-1" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<span
key={i}
className="tstxv-bar w-1 rounded-full bg-white/90"
style={{ height: "100%", animationDelay: `${i * 0.15}s` }}
/>
))}
</span>
) : null}
</div>
<div className="absolute inset-x-3 bottom-3 flex items-center gap-3 rounded-xl bg-slate-950/45 px-3 py-2 backdrop-blur-md">
<button
type="button"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="text-white transition hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-0"
>
{playing ? <PauseIcon className="h-5 w-5" /> : <PlayIcon className="h-5 w-5" />}
</button>
<span className="w-24 shrink-0 text-xs font-medium tabular-nums text-white/90">
{fmt(currentTime)} / {fmt(durationSec)}
</span>
<input
type="range"
min={0}
max={100}
step={0.5}
value={progress}
onChange={onSeek}
aria-label="Seek video"
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-white/30 accent-indigo-400"
/>
<button
type="button"
onClick={() => setMuted((m) => !m)}
aria-label={muted ? "Unmute" : "Mute"}
aria-pressed={muted}
className="text-white transition hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<VolumeIcon muted={muted} className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-6 sm:p-8">
<QuoteMark className="h-7 w-7 text-indigo-400" />
<blockquote className="mt-3 text-pretty text-lg font-medium leading-relaxed text-slate-800 dark:text-slate-100">
{active.quote}
</blockquote>
<div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-3 border-t border-slate-100 pt-5 dark:border-slate-800">
<span className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm dark:bg-slate-800">
<span className="font-bold text-slate-900 dark:text-white">
{active.metric.value}
</span>
<span className="text-slate-500 dark:text-slate-400">
{active.metric.label}
</span>
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{CATEGORY_LABEL[active.category]} · {fmt(active.durationSec)} story
</span>
</div>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</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 →
Three Card Testimonial Grid
OriginalA clean three-column grid of testimonial cards, each with a star rating, quote and an initial avatar with name and role.

Single Featured Quote
OriginalA large centred pull quote with a decorative quotation mark, five-star rating and a prominent author avatar for a single standout testimonial.

Logo Wall Marquee
OriginalA pair of infinitely scrolling, pause-on-hover marquees pairing a customer logo wall with compact quote cards, with reduced-motion support.

Masonry Testimonial Wall
OriginalA CSS-columns masonry wall of testimonial cards in varied lengths, each with a star rating and initial avatar for a natural, organic layout.

Aggregate Rating Spotlight
OriginalA split layout pairing a large aggregate rating score, star row and overlapping avatar stack with two featured review cards.

Auto-Rotating Quote Carousel
OriginalAn auto-advancing testimonial carousel that slides and crossfades between quotes with a live progress bar, pause-on-hover, and dot plus arrow navigation.

Staggered Reveal Testimonial Grid
OriginalA testimonial grid whose cards spring in with a staggered scroll reveal and light up with an animated gradient border on hover.

Infinite Marquee Testimonial Wall
OriginalA three-row marquee wall of review cards scrolling in alternating directions that pause on hover, with a shimmering gradient headline and faded edges.

Grid Testimonial
Originaltestimonial grid

Single Large Testimonial
Originalsingle large quote

Marquee Testimonial
Originaltestimonial marquee rows
Cards Avatar Testimonial
Originaltestimonial cards with avatars

