Animated Cards
In this craft, we will create a testimonial card that flips on clicks and shows the testimonial.
- Author
- Faisal Husain
- Published
- Reading time
- 4 min read
- Share
Sarah ChenProduct Manager at TechFlow
The attention to detail and innovative features have completely transformed our workflow. This is exactly what we've been looking for.This is just a extenstion of my previous craft 3D Flip Card where we created a 3D flip card. In this craft we will create a testimonial card that flips on clicks and shows the testimonial.
Here is code for the component -
'use client'
import { useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { ArrowBigLeft, ArrowBigRight } from 'lucide-react'
const GRID_SIZE = 10
const COUNT = GRID_SIZE * GRID_SIZE
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-1560250097-0b93528c311a?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8cHJvZmVzc2lvbmFsJTIwbWFufGVufDB8fDB8fHww',
},
{
quote:
"This solution has significantly improved our team's productivity. The intuitive interface makes complex tasks simple.",
name: 'Emily Watson',
designation: 'Operations Director at CloudScale',
src:
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8cHJvZmVzc2lvbmFsJTIwd29tYW58ZW58MHx8MHx8fDA%3D',
},
{
quote:
"Outstanding support and robust features. It's rare to find a product that delivers on all its promises.",
name: 'James Kim',
designation: 'Engineering Lead at DataPro',
src:
'https://images.unsplash.com/photo-1636041293178-808a6762ab39?q=80&w=3464&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
},
{
quote:
'The scalability and performance have been game-changing for our organization. Highly recommend to any growing business.',
name: 'Lisa Thompson',
designation: 'VP of Technology at FutureNet',
src:
'https://images.unsplash.com/photo-1624561172888-ac93c696e10c?q=80&w=2592&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
},
]
const Testimonial3DCard = () => {
const [flipped, setFlipped] = useState(false)
const [clickPosition, setClickPosition] = useState({ x: 0, y: 0 })
const [isAnimating, setIsAnimating] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [nextIndex, setNextIndex] = useState(1)
const getManhattanDistance = (
x1: number,
y1: number,
x2: number,
y2: number,
) => {
return Math.abs(x2 - x1) + Math.abs(y2 - y1)
}
const maxDistance = getManhattanDistance(0, 0, GRID_SIZE - 1, GRID_SIZE - 1)
const totalAnimationDuration = maxDistance * 0.1 + 0.5
const getRandomGridPosition = () => ({
x: Math.floor(Math.random() * GRID_SIZE),
y: Math.floor(Math.random() * GRID_SIZE),
})
const triggerFlip = (randomPosition?: { x: number; y: number }) => {
const position = randomPosition || getRandomGridPosition()
setClickPosition(position)
setFlipped((prev) => !prev)
setIsAnimating(true)
setTimeout(() => {
if (!flipped) {
setCurrentIndex((pre) => (pre + 2) % testimonials.length)
} else {
setNextIndex((pre) => (pre + 2) % testimonials.length)
}
setIsAnimating(false)
}, totalAnimationDuration * 1000)
}
const handleNextClick = () => {
if (isAnimating) return
triggerFlip()
}
const handlePrevClick = () => {
if (isAnimating) return
triggerFlip()
}
return (
<div
className="bg-black ring-inset ring-2 ring-zinc-800 rounded-2xl items-center justify-center w-full grid md:grid-cols-2 grid-cols-1 p-5 "
style={
{
'--current-image': `url(${testimonials[currentIndex].src})`,
'--next-image': `url(${testimonials[nextIndex].src})`,
} as React.CSSProperties
}
>
<div className="flex flex-col items-center justify-center px-10 max-w-xl">
<AnimatePresence mode="wait">
<motion.div className=" text-white w-full h-40 ">
{flipped ? (
<motion.div
key="next"
initial={{ y: 20, opacity: 0, filter: 'blur(20px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: -20, opacity: 0, filter: 'blur(20px)' }}
transition={{
ease: 'easeInOut',
duration: totalAnimationDuration * 0.75,
}}
className="flex flex-col items-start space-y-4"
>
<div className="flex flex-col items-start">
<span className="text-2xl font-bold">
{testimonials[nextIndex].name}
</span>
<span className="text-xm text-gray-400 font-light">
{testimonials[nextIndex].designation}
</span>
</div>
<span className="text-sm">{testimonials[nextIndex].quote}</span>
</motion.div>
) : (
<motion.div
key="current"
initial={{ y: 20, opacity: 0, filter: 'blur(20px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: -20, opacity: 0, filter: 'blur(20px)' }}
transition={{
ease: 'easeInOut',
duration: totalAnimationDuration * 0.75,
}}
className="flex flex-col items-start space-y-4"
>
<div className="flex flex-col items-start">
<span className="text-2xl font-bold">
{testimonials[currentIndex].name}
</span>
<span className="text-xm text-gray-400 font-light">
{testimonials[currentIndex].designation}
</span>
</div>
<span className="text-sm">
{testimonials[currentIndex].quote}
</span>
</motion.div>
)}
</motion.div>
</AnimatePresence>
<div className="flex gap-4 mt-10 w-full z-50 ">
<button
onClick={handlePrevClick}
disabled={isAnimating}
className="size-8 rounded-full bg-gray-100 dark:bg-neutral-800 flex items-center justify-center group/button disabled:opacity-50"
>
<ArrowBigLeft className=" size-6 text-black dark:text-neutral-400 group-hover/button:rotate-12 transition-transform duration-300" />
</button>
<button
onClick={handleNextClick}
disabled={isAnimating}
className="size-8 rounded-full bg-gray-100 dark:bg-neutral-800 flex items-center justify-center group/button disabled:opacity-50"
>
<ArrowBigRight className="size-6 text-black dark:text-neutral-400 group-hover/button:-rotate-12 transition-transform duration-300" />
</button>
</div>
</div>
<div
className={`grid grid-cols-10 md:size-[300px] size-[300px] mx-auto ${
isAnimating ? 'pointer-events-none' : ''
} `}
>
{Array.from({ length: COUNT }, (_, index) => {
const x = index % GRID_SIZE
const y = Math.floor(index / GRID_SIZE)
return (
<motion.div
key={index}
className={`relative w-full h-full cursor-pointer [transform-style:preserve-3d] transition-transform duration-500`}
initial="initial"
animate={{
rotateX: flipped ? 180 : 0,
transition: {
rotateX: {
duration: 0.1,
delay:
getManhattanDistance(
clickPosition.x,
clickPosition.y,
x,
y,
) * 0.1,
},
},
}}
style={
{
'--x': x,
'--y': y,
'--grid-size': GRID_SIZE,
} as React.CSSProperties
}
>
<div
className="absolute w-full h-full bg-blue-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden] [transform:rotateY(0deg)]"
style={{
backgroundImage: 'var(--current-image)',
backgroundPosition: `calc(var(--x, 0) * -100%) calc(var(--y, 0) * -100%)`,
backgroundSize: `calc(var(--grid-size, 10) * 100%)`,
}}
/>
<div
className="absolute w-full h-full bg-red-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden] [transform:rotateX(180deg)]"
style={{
backgroundImage: 'var(--next-image)',
backgroundPosition: `calc(var(--x, 0) * -100%) calc(var(--y, 0) * -100%)`,
backgroundSize: `calc(var(--grid-size, 10) * 100%)`,
}}
/>
</motion.div>
)
})}
</div>
</div>
)
}
export default Testimonial3DCard
Resources and Important Links
- Link to my Craft. Click here
Let's work together
Have a product problem worth solving?
I help teams build fast, dependable products with thoughtful interfaces and strong engineering foundations.