Components
Loading preview...
Adaptive Layout: Automatically adjusts visible testimonials (1-3) based on screen size Smooth Animations: Spring-based transitions with drag-and-drop functionality Auto-Play: Rotates testimonials every 4 seconds with pause on interaction Interactive Controls: Navigation buttons and dot indicators with hover effects Mobile Optimized: Touch-friendly swipe gestures with reduced swipe threshold Visual Feedback: Animated user avatars and active slide indicators Accessible: ARIA labels and keyboard-navigable controls Modern Design: Gradient accents, card shadows, and clean typography Performance: Optimized image loading with error fallbacks
@rf-rifat
npx shadcn@latest add https://21st.dev/r/rf-rifat/testimonial-slider"use client"
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight, Quote } from 'lucide-react';
import Image from 'next/image';
interface Testimonial {
id: number;
quote: string;
name: string;
username: string;
avatar: string;
}
const testimonials: Testimonial[] = [
{ id: 1, quote: "Impressed by the professionalism and attention to detail.", name: "Guy Hawkins", username: "@guyhawkins", avatar: "https://images.unsplash.com/photo-1633332755192-727a05c4013d?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8dXNlcnxlbnwwfHwwfHx8MA%3D%3D" },
{ id: 2, quote: "A seamless experience from start to finish. Highly recommend!", name: "Karla Lynn", username: "@karlalynn8", avatar: "https://images.unsplash.com/photo-1633332755192-727a05c4013d?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8dXNlcnxlbnwwfHwwfHx8MA%3D%3D" },
{ id: 3, quote: "Reliable and trustworthy. Made my life so much easier!", name: "Jane Cooper", username: "@janecooper", avatar: "https://images.unsplash.com/photo-1633332755192-727a05c4013d?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8dXNlcnxlbnwwfHwwfHx8MA%3D%3D" },
{ id: 4, quote: "The level of service exceeded my expectations. Will definitely come back.", name: "Robert Chen", username: "@robertchen", avatar: "https://images.unsplash.com/photo-1633332755192-727a05c4013d?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8dXNlcnxlbnwwfHwwfHx8MA%3D%3D" },
{ id: 5, quote: "An innovative approach that truly solved my problems.", name: "Sarah Miller", username: "@sarahmiller", avatar: "https://images.unsplash.com/photo-1633332755192-727a05c4013d?fm=jpg&q=60&w=3000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8dXNlcnxlbnwwfHwwfHx8MA%3D%3D" },
];
const getVisibleCount = (width: number): number => {
if (width >= 1280) return 3;
if (width >= 768) return 2;
return 1;
};
const TestimonialSlider: React.FC = () => {
const [currentIndex, setCurrentIndex] = useState(0);
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1024);
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
const [direction, setDirection] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
const newWidth = window.innerWidth;
setWindowWidth(newWidth);
const oldVisibleCount = getVisibleCount(windowWidth);
const newVisibleCount = getVisibleCount(newWidth);
if (oldVisibleCount !== newVisibleCount) {
const maxIndexForNewWidth = testimonials.length - newVisibleCount;
if (currentIndex > maxIndexForNewWidth) {
setCurrentIndex(Math.max(0, maxIndexForNewWidth));
}
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [windowWidth, currentIndex]);
useEffect(() => {
if (!isAutoPlaying) return;
const startAutoPlay = () => {
autoPlayRef.current = setInterval(() => {
const visibleCount = getVisibleCount(windowWidth);
const maxIndex = testimonials.length - visibleCount;
if (currentIndex >= maxIndex) {
setDirection(-1);
setCurrentIndex((prev) => prev - 1);
} else if (currentIndex <= 0) {
setDirection(1);
setCurrentIndex((prev) => prev + 1);
} else {
setCurrentIndex((prev) => prev + direction);
}
}, 4000);
};
startAutoPlay();
return () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
};
}, [isAutoPlaying, currentIndex, windowWidth, direction]);
const visibleCount = getVisibleCount(windowWidth);
const maxIndex = testimonials.length - visibleCount;
const canGoNext = currentIndex < maxIndex;
const canGoPrev = currentIndex > 0;
const goNext = () => {
if (canGoNext) {
setDirection(1);
setCurrentIndex((prev) => Math.min(prev + 1, maxIndex));
pauseAutoPlay();
}
};
const goPrev = () => {
if (canGoPrev) {
setDirection(-1);
setCurrentIndex((prev) => Math.max(prev - 1, 0));
pauseAutoPlay();
}
};
const pauseAutoPlay = () => {
setIsAutoPlaying(false);
setTimeout(() => setIsAutoPlaying(true), 8000);
};
const handleDragEnd = (event: any, info: any) => {
const { offset } = info;
const swipeThreshold = 30;
if (offset.x < -swipeThreshold && canGoNext) {
goNext();
} else if (offset.x > swipeThreshold && canGoPrev) {
goPrev();
}
};
const goToSlide = (index: number) => {
setCurrentIndex(index);
pauseAutoPlay();
};
return (
<div className="px-4 py-8 sm:py-16 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-800 overflow-hidden">
<div className="max-w-6xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-8 sm:mb-12 md:mb-16"
>
<span className="inline-block py-1 px-3 rounded-full bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light font-medium text-xs sm:text-sm uppercase tracking-wider">
Testimonials
</span>
<h3 className="text-2xl sm:text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary to-primary/70 dark:from-primary-light dark:to-primary bg-clip-text text-transparent mt-3 sm:mt-4 px-4">
Transformative Student Experiences
</h3>
<div className="w-16 sm:w-24 h-1 bg-gradient-to-r from-primary to-primary/70 dark:from-primary-light dark:to-primary mx-auto mt-4 sm:mt-6"></div>
</motion.div>
<div className="relative" ref={containerRef}>
<div className="flex justify-center sm:justify-end sm:absolute sm:-top-16 right-0 space-x-2 mb-4 sm:mb-0">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={goPrev}
disabled={!canGoPrev}
className={`p-2 rounded-full ${
canGoPrev
? 'bg-white dark:bg-gray-700 shadow-md hover:bg-gray-50 dark:hover:bg-gray-600 text-primary dark:text-primary-light'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
} transition-all duration-300`}
aria-label="Previous testimonial"
>
<ChevronLeft size={20} className="w-4 h-4 sm:w-5 sm:h-5" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={goNext}
disabled={!canGoNext}
className={`p-2 rounded-full ${
canGoNext
? 'bg-white dark:bg-gray-700 shadow-md hover:bg-gray-50 dark:hover:bg-gray-600 text-primary dark:text-primary-light'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 cursor-not-allowed'
} transition-all duration-300`}
aria-label="Next testimonial"
>
<ChevronRight size={20} className="w-4 h-4 sm:w-5 sm:h-5" />
</motion.button>
</div>
<div className="overflow-hidden relative px-2 sm:px-0">
<motion.div
className="flex"
animate={{ x: `-${currentIndex * (100 / visibleCount)}%` }}
transition={{
type: 'spring',
stiffness: 70,
damping: 20
}}
>
{testimonials.map((testimonial) => (
<motion.div
key={testimonial.id}
className={`flex-shrink-0 w-full ${
visibleCount === 3 ? 'md:w-1/3' :
visibleCount === 2 ? 'md:w-1/2' : 'w-full'
} p-2`}
initial={{ opacity: 0.5, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.2}
onDragEnd={handleDragEnd}
whileHover={{ y: -5 }}
whileTap={{ scale: 0.98, cursor: 'grabbing' }}
style={{ cursor: 'grab' }}
>
<motion.div
className="relative overflow-hidden rounded-xl sm:rounded-2xl p-4 sm:p-6 h-full bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 shadow-lg shadow-primary/5 dark:shadow-primary-light/5"
whileHover={{
boxShadow: "0 10px 15px -3px rgba(79, 70, 229, 0.1), 0 4px 6px -2px rgba(79, 70, 229, 0.05)"
}}
>
<div className="absolute -top-4 -left-4 opacity-10 dark:opacity-20">
<Quote size={windowWidth < 640 ? 40 : 60} className="text-primary dark:text-primary-light" />
</div>
<div className="relative z-10 h-full flex flex-col">
<p className="text-sm sm:text-base text-gray-700 dark:text-gray-300 font-medium mb-4 sm:mb-6 leading-relaxed">
“{testimonial.quote}”
</p>
<div className="mt-auto pt-3 sm:pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center">
<div className="relative flex-shrink-0">
<Image
width={48}
height={48}
src={testimonial.avatar}
alt={testimonial.name}
className="w-8 h-8 sm:w-10 sm:h-10 rounded-full object-cover border-2 border-white dark:border-gray-800 shadow-sm"
onError={(e) => {
(e.target as HTMLImageElement).src = '/api/placeholder/48/48';
}}
/>
<motion.div
className="absolute inset-0 rounded-full bg-primary/20 dark:bg-primary-light/20"
animate={{
scale: [1, 1.2, 1],
opacity: [0, 0.3, 0]
}}
transition={{
duration: 2,
repeat: Infinity,
repeatDelay: 1
}}
/>
</div>
<div className="ml-3">
<h4 className="font-bold text-sm sm:text-base text-gray-900 dark:text-white">{testimonial.name}</h4>
<p className="text-gray-600 dark:text-gray-400 text-xs sm:text-sm">{testimonial.username}</p>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
))}
</motion.div>
</div>
<div className="flex justify-center mt-6 sm:mt-8">
{Array.from({ length: testimonials.length - visibleCount + 1 }, (_: any, index: any) => (
<motion.button
key={index}
onClick={() => goToSlide(index)}
className="relative mx-1 focus:outline-none"
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
aria-label={`Go to testimonial ${index + 1}`}
>
<motion.div
className={`w-2 h-2 rounded-full ${
index === currentIndex
? 'bg-primary dark:bg-primary-light'
: 'bg-gray-300 dark:bg-gray-600'
}`}
animate={{
scale: index === currentIndex ? [1, 1.2, 1] : 1
}}
transition={{
duration: 1.5,
repeat: index === currentIndex ? Infinity : 0,
repeatDelay: 1
}}
/>
{index === currentIndex && (
<motion.div
className="absolute inset-0 rounded-full bg-primary/30 dark:bg-primary-light/30"
animate={{
scale: [1, 1.8],
opacity: [1, 0]
}}
transition={{
duration: 1.5,
repeat: Infinity,
}}
/>
)}
</motion.button>
))}
</div>
</div>
</div>
</div>
);
};
export default TestimonialSlider;