Testimonial Carousel
Original · freetestimonial quote carousel
byWeb InnoventixReact + Tailwind
cartestimonialcarousels
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/car-testimonial.jsoncar-testimonial.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
type Testimonial = {
quote: string;
name: string;
role: string;
company: string;
rating: number;
from: string;
to: string;
accentText: string;
};
const TESTIMONIALS: Testimonial[] = [
{
quote:
"We cut our monthly close from nine days to three. The reconciliation view alone paid for the first year inside a single quarter — my team stopped dreading the last week of the month.",
name: "Priya Nair",
role: "VP of Finance",
company: "Meridian Logistics",
rating: 5,
from: "from-indigo-500",
to: "to-violet-500",
accentText: "text-indigo-500 dark:text-indigo-400",
},
{
quote:
"Onboarding used to eat a full week of engineering time per client. Now it's a guided flow our success team runs without us. That gap is the difference between shipping features and firefighting.",
name: "Daniel Okafor",
role: "Staff Engineer",
company: "Cavalry Health",
rating: 5,
from: "from-emerald-500",
to: "to-sky-500",
accentText: "text-emerald-500 dark:text-emerald-400",
},
{
quote:
"I was bracing for a painful migration of forty thousand records. The importer mapped every field correctly on the first pass, and we did zero manual cleanup afterward. I genuinely didn't believe it at first.",
name: "Sofia Marchetti",
role: "Head of Operations",
company: "Brightline Studios",
rating: 5,
from: "from-rose-500",
to: "to-amber-500",
accentText: "text-rose-500 dark:text-rose-400",
},
{
quote:
"When launch traffic spiked twelve-times overnight, nothing broke and nobody had to page me at 3 a.m. Support answers in minutes, not tickets. That reliability is why we renewed for three years.",
name: "Marcus Bell",
role: "Chief Technology Officer",
company: "Tidewater Labs",
rating: 5,
from: "from-sky-500",
to: "to-indigo-500",
accentText: "text-sky-500 dark:text-sky-400",
},
{
quote:
"It's the first reporting our board actually reads. I stopped exporting to spreadsheets three months ago and I am never going back. Decisions that used to take a week now happen in the room.",
name: "Aisha Rahman",
role: "Founder & CEO",
company: "Kestrel Commerce",
rating: 5,
from: "from-violet-500",
to: "to-rose-500",
accentText: "text-violet-500 dark:text-violet-400",
},
];
const COUNT = TESTIMONIALS.length;
const DURATION = 6500;
function initials(name: string): string {
return name
.split(" ")
.map((word) => word[0])
.slice(0, 2)
.join("");
}
export default function CarTestimonial() {
const reduceMotion = useReducedMotion();
const [index, setIndex] = useState(0);
const [direction, setDirection] = useState(1);
const [userPaused, setUserPaused] = useState(false);
const [hovering, setHovering] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const [progress, setProgress] = useState(0);
const elapsedRef = useRef(0);
const indexRef = useRef(index);
indexRef.current = index;
const playing = !userPaused && !hovering && !focusWithin && !reduceMotion;
const goTo = useCallback((target: number, dir: number) => {
setDirection(dir);
setIndex(((target % COUNT) + COUNT) % COUNT);
elapsedRef.current = 0;
setProgress(0);
}, []);
const next = useCallback(() => goTo(indexRef.current + 1, 1), [goTo]);
const prev = useCallback(() => goTo(indexRef.current - 1, -1), [goTo]);
useEffect(() => {
if (!playing) return;
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = now - last;
last = now;
elapsedRef.current += dt;
if (elapsedRef.current >= DURATION) {
elapsedRef.current = 0;
setProgress(0);
goTo(indexRef.current + 1, 1);
} else {
setProgress(elapsedRef.current / DURATION);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [playing, goTo]);
const handleDragEnd = (
_event: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo,
) => {
const threshold = 64;
if (info.offset.x < -threshold || info.velocity.x < -420) next();
else if (info.offset.x > threshold || info.velocity.x > 420) prev();
};
const active = TESTIMONIALS[index];
const distance = reduceMotion ? 0 : 52;
return (
<section
aria-labelledby="car-testimonial-heading"
className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28"
>
<style>{`
@keyframes carTestimonial-blob {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(18px, -22px, 0) scale(1.12); }
}
@keyframes carTestimonial-sheen {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.carTestimonial-blob { animation: carTestimonial-blob 16s ease-in-out infinite; }
.carTestimonial-sheen {
background-size: 200% 100%;
animation: carTestimonial-sheen 3.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.carTestimonial-blob, .carTestimonial-sheen { animation: none !important; }
}
`}</style>
{/* Ambient background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="carTestimonial-blob absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="carTestimonial-blob absolute -bottom-32 right-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
<div
className="absolute inset-0 opacity-[0.15] dark:opacity-[0.08]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)",
backgroundSize: "28px 28px",
color: "rgb(100 116 139)",
}}
/>
</div>
<div className="relative mx-auto max-w-3xl">
{/* Header */}
<div className="mb-10 flex flex-col items-center text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-slate-600 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Customer stories
</span>
<h2
id="car-testimonial-heading"
className="mt-5 text-balance text-3xl font-bold tracking-tight sm:text-4xl"
>
Teams that shipped faster after the switch
</h2>
<p className="mt-3 max-w-xl text-pretty text-base text-slate-500 dark:text-slate-400">
Real words from the operators, engineers and founders who run their day-to-day on the platform.
</p>
</div>
{/* Carousel */}
<div
role="region"
aria-roledescription="carousel"
aria-label="Customer testimonials"
tabIndex={0}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocus={() => setFocusWithin(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setFocusWithin(false);
}
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
prev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
next();
} else if (e.key === "Home") {
e.preventDefault();
goTo(0, -1);
} else if (e.key === "End") {
e.preventDefault();
goTo(COUNT - 1, 1);
}
}}
className="group relative rounded-3xl border border-slate-200 bg-white/80 p-1 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)] outline-none backdrop-blur-xl transition focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)] dark:focus-visible:ring-offset-slate-950"
>
{/* Top accent bar */}
<div className="mx-1 h-1 overflow-hidden rounded-full">
<div
className={`carTestimonial-sheen h-full w-full bg-gradient-to-r ${active.from} ${active.to}`}
/>
</div>
<div
className="relative min-h-[360px] px-6 py-8 sm:min-h-[320px] sm:px-12 sm:py-12"
aria-live={playing ? "off" : "polite"}
aria-atomic="true"
>
{/* Decorative quote mark */}
<svg
aria-hidden="true"
viewBox="0 0 48 48"
className="absolute right-6 top-6 h-16 w-16 text-slate-100 dark:text-slate-800 sm:right-10 sm:top-8 sm:h-24 sm:w-24"
fill="currentColor"
>
<path d="M18 10C11.4 12.6 7 18.9 7 26.5V38h14V24h-7c0-3.9 2.4-7.2 6-8.7L18 10Zm23 0c-6.6 2.6-11 8.9-11 16.5V38h14V24h-7c0-3.9 2.4-7.2 6-8.7L41 10Z" />
</svg>
<AnimatePresence mode="wait" custom={direction}>
<motion.blockquote
key={index}
custom={direction}
initial={{ opacity: 0, x: distance * direction }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -distance * direction }}
transition={{ duration: reduceMotion ? 0 : 0.42, ease: "easeOut" }}
drag={reduceMotion ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={handleDragEnd}
className="relative touch-pan-y select-none"
>
{/* Stars */}
<div className="mb-5 flex items-center gap-1" aria-label={`Rated ${active.rating} out of 5`}>
{Array.from({ length: 5 }).map((_, s) => (
<svg
key={s}
aria-hidden="true"
viewBox="0 0 20 20"
className={`h-4 w-4 ${
s < active.rating
? active.accentText
: "text-slate-200 dark:text-slate-700"
}`}
fill="currentColor"
>
<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-.8L10 1.6Z" />
</svg>
))}
</div>
<p className="text-pretty text-xl font-medium leading-relaxed text-slate-800 dark:text-slate-100 sm:text-2xl sm:leading-relaxed">
“{active.quote}”
</p>
<footer className="mt-8 flex items-center gap-4">
<span
aria-hidden="true"
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${active.from} ${active.to} text-sm font-bold text-white shadow-lg ring-2 ring-white/70 dark:ring-white/10`}
>
{initials(active.name)}
</span>
<span className="flex flex-col">
<cite className="text-base font-semibold not-italic text-slate-900 dark:text-white">
{active.name}
</cite>
<span className="text-sm text-slate-500 dark:text-slate-400">
{active.role}, {active.company}
</span>
</span>
</footer>
</motion.blockquote>
</AnimatePresence>
</div>
{/* Progress track */}
{!reduceMotion && (
<div
aria-hidden="true"
className="mx-6 mb-1 h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800 sm:mx-12"
>
<div
className={`h-full origin-left rounded-full bg-gradient-to-r ${active.from} ${active.to}`}
style={{ transform: `scaleX(${progress})` }}
/>
</div>
)}
</div>
{/* Controls */}
<div className="mt-7 flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={prev}
aria-label="Previous testimonial"
className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button
type="button"
onClick={next}
aria-label="Next testimonial"
className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
{/* Dots */}
<div className="flex items-center gap-2.5" role="group" aria-label="Choose testimonial">
{TESTIMONIALS.map((t, i) => {
const isActive = i === index;
return (
<button
key={t.name}
type="button"
onClick={() => goTo(i, i > index ? 1 : -1)}
aria-label={`Show testimonial from ${t.name}`}
aria-current={isActive ? "true" : undefined}
className={`h-2.5 rounded-full transition-all duration-300 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 ${
isActive
? "w-7 bg-slate-900 dark:bg-white"
: "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600"
}`}
/>
);
})}
</div>
{/* Play / pause + counter */}
<div className="flex items-center gap-3">
<span className="hidden font-mono text-sm tabular-nums text-slate-400 sm:inline dark:text-slate-500">
{String(index + 1).padStart(2, "0")}
<span className="text-slate-300 dark:text-slate-700"> / {String(COUNT).padStart(2, "0")}</span>
</span>
{!reduceMotion && (
<button
type="button"
onClick={() => setUserPaused((p) => !p)}
aria-label={userPaused ? "Start autoplay" : "Pause autoplay"}
aria-pressed={userPaused}
className="flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:translate-y-0 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-950"
>
{userPaused ? (
<svg viewBox="0 0 24 24" className="h-5 w-5 translate-x-px" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7L8 5Z" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<path d="M7 5h3v14H7V5Zm7 0h3v14h-3V5Z" />
</svg>
)}
</button>
)}
</div>
</div>
</div>
</section>
);
}Dependencies
motion
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 →
Basic Carousel
Originalbasic image carousel with arrows and dots

Fade Carousel
Originalcrossfade carousel

Cards Carousel
Originalpeeking card carousel

Coverflow Carousel
Original3D coverflow carousel
Thumbnails Carousel
Originalcarousel synced with thumbnails

Autoplay Carousel
Originalautoplay carousel with progress and pause

Dots Carousel
Originalcarousel with animated dot indicators

Vertical Carousel
Originalvertical carousel

Multi Carousel
Originalmulti-item responsive carousel

Center Focus Carousel
Originalcentre-focused scaling carousel

Progress Carousel
Originalcarousel with a progress bar

Logos Carousel
Originallogo carousel strip

