Docs
Animated Testimonials
Minimal testimonials sections with image and quote.
Installation
Install dependencies
npm i motion clsx tailwind-merge
Add util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Run the following command
It will create a new file animated-testimonials.tsx
inside the components/mage-ui/card
directory.
mkdir -p components/mage-ui/card && touch components/mage-ui/card/animated-testimonials.tsx
Paste the code
Open the newly created file and paste the following code:
"use client";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { useEffect, useState, useCallback } from "react";
type Testimonial = {
quote: string;
name: string;
designation: string;
src: string;
};
const AnimatedTestimonials = ({
testimonials,
autoplay = false,
}: {
testimonials: Testimonial[];
autoplay?: boolean;
}) => {
const [active, setActive] = useState(0);
const handleNext = useCallback(() => {
setActive((prev) => (prev + 1) % testimonials.length);
}, [testimonials.length]);
const handlePrev = useCallback(() => {
setActive((prev) => (prev - 1 + testimonials.length) % testimonials.length);
}, [testimonials.length]);
const isActive = (index: number) => index === active;
useEffect(() => {
if (autoplay) {
const interval = setInterval(handleNext, 5000);
return () => clearInterval(interval);
}
}, [autoplay, handleNext]);
const randomRotateY = () => Math.floor(Math.random() * 21) - 10;
return (
<div className="max-w-sm md:max-w-4xl mx-auto antialiased font-sans px-4 md:px-8 lg:px-12 py-20">
<div className="relative grid grid-cols-1 md:grid-cols-2 gap-20">
<div className="relative h-80 w-full">
<AnimatePresence>
{testimonials.map((testimonial, index) => (
<motion.div
key={testimonial.src}
initial={{ opacity: 0, scale: 0.9, z: -100, rotate: randomRotateY() }}
animate={{
opacity: isActive(index) ? 1 : 0.7,
scale: isActive(index) ? 1 : 0.95,
z: isActive(index) ? 0 : -100,
rotate: isActive(index) ? 0 : randomRotateY(),
zIndex: isActive(index) ? 999 : testimonials.length + 2 - index,
y: isActive(index) ? [0, -80, 0] : 0,
}}
exit={{ opacity: 0, scale: 0.9, z: 100, rotate: randomRotateY() }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="absolute inset-0 origin-bottom"
>
<Image
src={testimonial.src}
alt={testimonial.name}
width={500}
height={500}
draggable={false}
className="h-full w-full rounded-3xl object-cover object-center"
/>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="flex justify-between flex-col py-4">
<motion.div
key={active}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<h3 className="text-2xl font-bold dark:text-white text-black">
{testimonials[active].name}
</h3>
<p className="text-sm text-gray-500 dark:text-neutral-500">
{testimonials[active].designation}
</p>
<motion.p className="text-lg text-gray-500 mt-8 dark:text-neutral-300">
{testimonials[active].quote.split(" ").map((word, index) => (
<motion.span
key={index}
initial={{ filter: "blur(10px)", opacity: 0, y: 5 }}
animate={{ filter: "blur(0px)", opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeInOut", delay: 0.02 * index }}
className="inline-block"
>
{word}
</motion.span>
))}
</motion.p>
</motion.div>
<div className="flex gap-4 pt-12 md:pt-0">
<button onClick={handlePrev} className="h-7 w-7 rounded-full bg-gray-100 dark:bg-neutral-800 flex items-center justify-center group/button">
<IconArrowLeft className="h-5 w-5 text-black dark:text-neutral-400 group-hover/button:rotate-12 transition-transform duration-300" />
</button>
<button onClick={handleNext} className="h-7 w-7 rounded-full bg-gray-100 dark:bg-neutral-800 flex items-center justify-center group/button">
<IconArrowRight className="h-5 w-5 text-black dark:text-neutral-400 group-hover/button:-rotate-12 transition-transform duration-300" />
</button>
</div>
</div>
</div>
</div>
);
};
export default function AnimatedTestimonialsPage() {
const testimonials = [
{
quote: "The attention to detail and innovative features have completely transformed our workflow. This is exactly what we've been looking for.",
name: "Sarah Chen",
designation: "Product Manager at TechFlow",
src: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=3560&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
quote: "Implementation was seamless and the results exceeded our expectations. The platform's flexibility is remarkable.",
name: "Michael Rodriguez",
designation: "CTO at InnovateSphere",
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
];
return <AnimatedTestimonials testimonials={testimonials} autoplay={true} />;
}