Social proof drives conversions. A testimonial carousel lets you showcase multiple reviews in limited space. Here is how to build one from scratch.
Step 1: Define Testimonial Data
type Testimonial = {
id: number;
name: string;
role: string;
company: string;
content: string;
avatar: string;
rating: number;
};
const testimonials: Testimonial[] = [
{
id: 1,
name: "Sarah Mitchell",
role: "Marketing Director",
company: "GrowthCo",
content:
"They delivered our new website two weeks ahead of schedule. Traffic increased 180% in the first quarter. The ROI has been incredible.",
avatar: "/images/avatars/sarah.jpg",
rating: 5,
},
{
id: 2,
name: "James Park",
role: "Founder",
company: "TechStart",
content:
"Our custom software solution handles 10x the traffic of our previous system. The team understood our technical requirements perfectly.",
avatar: "/images/avatars/james.jpg",
rating: 5,
},
{
id: 3,
name: "Maria Rodriguez",
role: "Operations Manager",
company: "ServicePro",
content:
"The mobile app they built has a 4.8 star rating. Customer engagement doubled. They are now our go-to development partner.",
avatar: "/images/avatars/maria.jpg",
rating: 5,
},
];
Step 2: Build the Star Rating Component
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5" aria-label={`${rating} out of 5 stars`}>
{Array.from({ length: 5 }).map((_, i) => (
<svg
key={i}
className={`h-5 w-5 ${i < rating ? "text-yellow-400" : "text-gray-300"}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
);
}
Step 3: Build the Carousel
"use client";
import { useState, useEffect, useCallback } from "react";
export function TestimonialCarousel() {
const [current, setCurrent] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const next = useCallback(() => {
setCurrent((prev) => (prev + 1) % testimonials.length);
}, []);
const prev = useCallback(() => {
setCurrent((prev) => (prev - 1 + testimonials.length) % testimonials.length);
}, []);
// Autoplay
useEffect(() => {
if (isPaused) return;
const interval = setInterval(next, 5000);
return () => clearInterval(interval);
}, [isPaused, next]);
// Keyboard navigation
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [next, prev]);
const testimonial = testimonials[current];
return (
<section
className="py-20"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
aria-roledescription="carousel"
aria-label="Customer testimonials"
>
<div className="mx-auto max-w-3xl px-6 text-center">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">
What Our Clients Say
</h2>
<div
className="mt-12"
role="group"
aria-roledescription="slide"
aria-label={`${current + 1} of ${testimonials.length}`}
>
<StarRating rating={testimonial.rating} />
<blockquote className="mt-6">
<p className="text-xl leading-relaxed text-gray-700 dark:text-gray-300">
“{testimonial.content}”
</p>
</blockquote>
<div className="mt-6 flex items-center justify-center gap-4">
<img
src={testimonial.avatar}
alt={testimonial.name}
className="h-12 w-12 rounded-full object-cover"
/>
<div className="text-left">
<p className="font-semibold text-gray-900 dark:text-white">
{testimonial.name}
</p>
<p className="text-sm text-gray-500">
{testimonial.role}, {testimonial.company}
</p>
</div>
</div>
</div>
{/* Navigation */}
<div className="mt-10 flex items-center justify-center gap-4">
<button
onClick={prev}
className="rounded-full border p-2 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
aria-label="Previous testimonial"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Dots */}
<div className="flex gap-2">
{testimonials.map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={`h-2.5 w-2.5 rounded-full transition-colors ${
i === current ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
aria-label={`Go to testimonial ${i + 1}`}
/>
))}
</div>
<button
onClick={next}
className="rounded-full border p-2 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
aria-label="Next testimonial"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</section>
);
}
Step 4: Add Slide Transition Animation
For smoother transitions, add CSS:
/* globals.css */
.testimonial-enter {
opacity: 0;
transform: translateX(20px);
}
.testimonial-enter-active {
opacity: 1;
transform: translateX(0);
transition: opacity 300ms ease, transform 300ms ease;
}
Or use Framer Motion:
import { AnimatePresence, motion } from "framer-motion";
<AnimatePresence mode="wait">
<motion.div
key={current}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{/* testimonial content */}
</motion.div>
</AnimatePresence>
Step 5: Multi-Card Layout (Desktop)
Show three testimonials at once on larger screens:
function TestimonialGrid() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{testimonials.map((t) => (
<div
key={t.id}
className="rounded-xl border p-6 dark:border-gray-700"
>
<StarRating rating={t.rating} />
<p className="mt-4 text-gray-600 dark:text-gray-300">
“{t.content}”
</p>
<div className="mt-4 flex items-center gap-3">
<img
src={t.avatar}
alt={t.name}
className="h-10 w-10 rounded-full"
/>
<div>
<p className="text-sm font-medium">{t.name}</p>
<p className="text-xs text-gray-500">{t.company}</p>
</div>
</div>
</div>
))}
</div>
);
}
Accessibility Checklist
- Include
aria-roledescription="carousel"on the container - Use
aria-labelon each slide group - Pause autoplay on hover and focus
- Support keyboard navigation with arrow keys
- Ensure sufficient color contrast for text and controls
Need Social Proof for Your Website?
We design conversion-focused websites with testimonial sections that build trust. Contact us to get started.