How to Build a Fancy Testimonial Slider with Tailwind CSS and Next.js
Welcome to the second part of the series of How to Build a Fancy Testimonial Slider with Tailwind CSS! In the previous tutorial, we learned how to create a testimonial slider using Alpine.js. Now, get ready to take your skills to the next level, as we will build a powerful Next.js component that achieves the same stunning result.
The significance of customer testimonials in driving your visitors’ purchasing decisions for your product or service is widely acknowledged and understood, so if you want to have a look at how we previously developed this component for our templates, we recommend checking out our Dark Next.js landing page template called Stellar.
Let’s get started!
Creating the component
But enough talking, let’s start by creating a file fancy-testimonial-slider.tsx
for our component:
'use client'
import { useState } from 'react'
import Image, { StaticImageData } from 'next/image'
import TestimonialImg01 from '@/public/testimonial-01.jpg'
import TestimonialImg02 from '@/public/testimonial-02.jpg'
import TestimonialImg03 from '@/public/testimonial-03.jpg'
interface Testimonial {
img: StaticImageData
quote: string
name: string
role: string
}
export default function FancyTestimonialsSlider() {
const [active, setActive] = useState<number>(0)
const [autorotate, setAutorotate] = useState<boolean>(true)
const autorotateTiming: number = 7000
const testimonials: Testimonial[] = [
{
img: TestimonialImg01,
quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
name: 'Jessie J',
role: 'Acme LTD'
},
{
img: TestimonialImg02,
quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
name: 'Nick V',
role: 'Malika Inc.'
},
{
img: TestimonialImg03,
quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
name: 'Amelia W',
role: 'Panda AI'
}
]
return (
<div className="w-full max-w-3xl mx-auto text-center">
{/* ... */}
</div>
)
}
As usual, we have declared 'use client'
at the very top of the file to indicate that the component will be executed on the client-side. Additionally, we have created three state variables that are needed to control the slider:
-
active
to keep track of the active testimonial -
autorotate
to enable or disable automatic rotation -
autorotateTiming
to set the time for automatic rotation
Next, we’ve defined an array of testimonials with their respective properties. Leveraging TypeScript, we’ve also introduced the Testimonial
interface to specify the type of each array element.
Now, inside the component, we can start building the HTML structure:
'use client'
import { useState } from 'react'
import Image, { StaticImageData } from 'next/image'
import TestimonialImg01 from '@/public/testimonial-01.jpg'
import TestimonialImg02 from '@/public/testimonial-02.jpg'
import TestimonialImg03 from '@/public/testimonial-03.jpg'
interface Testimonial {
img: StaticImageData
quote: string
name: string
role: string
}
export default function FancyTestimonialsSlider() {
const [active, setActive] = useState<number>(0)
const [autorotate, setAutorotate] = useState<boolean>(true)
const autorotateTiming: number = 7000
const testimonials: Testimonial[] = [
{
img: TestimonialImg01,
quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
name: 'Jessie J',
role: 'Acme LTD'
},
{
img: TestimonialImg02,
quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
name: 'Nick V',
role: 'Malika Inc.'
},
{
img: TestimonialImg03,
quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
name: 'Amelia W',
role: 'Panda AI'
}
]
return (
<div className="w-full max-w-3xl mx-auto text-center">
{/* Testimonial image */}
<div className="relative h-32">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
<div className="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
{testimonials.map((testimonial, index) => (
<Image key={index} className="relative top-11 left-1/2 -translate-x-1/2 rounded-full" src={testimonial.img} width={56} height={56} alt={testimonial.name} />
))}
</div>
</div>
</div>
{/* Text */}
<div className="mb-9 transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col">
{testimonials.map((testimonial, index) => (
<div key={index} className="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{testimonial.quote}</div>
))}
</div>
</div>
{/* Buttons */}
<div className="flex flex-wrap justify-center -m-1.5">
{testimonials.map((testimonial, index) => (
<button
key={index}
className={`inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 ${active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'}`}
onClick={() => { setActive(index); }}
>
<span>{testimonial.name}</span> <span className={`${active === index ? 'text-indigo-200' : 'text-slate-300'}`}>-</span> <span>{testimonial.role}</span>
</button>
))}
</div>
</div>
)
}
As you can see, we have used the map()
method to iterate over the array of testimonials and dynamically generate the corresponding HTML elements. For each testimonial, we’ve created an Image
element to display the associated image, a div
to present the text, and a button
for the button.
In addition, we implemented the onClick()
handler to update the active testimonial index when a button is clicked.
However, the component is not yet functional as it renders all the images and text for each testimonial. Instead, we want to display only the active testimonial and introduce smooth transitions when switching between testimonials.
Managing the active testimonial and adding transitions
As we did with the modal video component created in a previous tutorial, we will use the Headless UI library for managing transitions. So, if we haven’t already, we need to install the library using the command npm install @headlessui/react@latest
.
Once the library is installed, make sure to import the Transition
component and wrap both the image and text of our testimonial within it. Also, don’t forget to include the key={index}
property within the Transition
component:
'use client'
import { useState } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Transition } from '@headlessui/react'
import TestimonialImg01 from '@/public/testimonial-01.jpg'
import TestimonialImg02 from '@/public/testimonial-02.jpg'
import TestimonialImg03 from '@/public/testimonial-03.jpg'
interface Testimonial {
img: StaticImageData
quote: string
name: string
role: string
}
export default function FancyTestimonialsSlider() {
const [active, setActive] = useState<number>(0)
const [autorotate, setAutorotate] = useState<boolean>(true)
const autorotateTiming: number = 7000
const testimonials: Testimonial[] = [
{
img: TestimonialImg01,
quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
name: 'Jessie J',
role: 'Acme LTD'
},
{
img: TestimonialImg02,
quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
name: 'Nick V',
role: 'Malika Inc.'
},
{
img: TestimonialImg03,
quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
name: 'Amelia W',
role: 'Panda AI'
}
]
return (
<div className="w-full max-w-3xl mx-auto text-center">
{/* Testimonial image */}
<div className="relative h-32">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
<div className="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
className="absolute inset-0 h-full -z-10"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 order-first"
enterFrom="opacity-0 -rotate-[60deg]"
enterTo="opacity-100 rotate-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700"
leaveFrom="opacity-100 rotate-0"
leaveTo="opacity-0 rotate-[60deg]"
beforeEnter={() => heightFix()}
>
<Image className="relative top-11 left-1/2 -translate-x-1/2 rounded-full" src={testimonial.img} width={56} height={56} alt={testimonial.name} />
</Transition>
))}
</div>
</div>
</div>
{/* Text */}
<div className="mb-9 transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col">
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 -translate-x-4"
enterTo="opacity-100 translate-x-0"
leave="transition ease-out duration-300 delay-300 absolute"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-4"
>
<div className="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{testimonial.quote}</div>
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="flex flex-wrap justify-center -m-1.5">
{testimonials.map((testimonial, index) => (
<button
key={index}
className={`inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 ${active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'}`}
onClick={() => { setActive(index); }}
>
<span>{testimonial.name}</span> <span className={`${active === index ? 'text-indigo-200' : 'text-slate-300'}`}>-</span> <span>{testimonial.role}</span>
</button>
))}
</div>
</div>
)
}
Improving transitions between testimonials with varying heights
At this point, to enhance the user experience when transitioning between testimonials with different heights, we will create a method called heightFix()
. This method calculates the height of the current testimonial and applies it to the container element. By incorporating the classes transition-all duration-150 delay-300 ease-in-out
to the container element, the height will transition smoothly when switching between testimonials of varying heights.
To accomplish this, we first need to reference the element for which we want to calculate the height using the ref={testimonialsRef}
attribute. Next, we will define the heightFix()
method:
const heightFix = () => {
if (testimonialsRef.current && testimonialsRef.current.parentElement) testimonialsRef.current.parentElement.style.height = `${testimonialsRef.current.clientHeight}px`
}
This method should be triggered in two situations: on component mount (using the useEffect
hook), and whenever there is a transition between testimonials, utilizing the beforeEnter
callback provided by the Transition
component:
'use client'
import { useState, useRef, useEffect } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Transition } from '@headlessui/react'
import TestimonialImg01 from '@/public/testimonial-01.jpg'
import TestimonialImg02 from '@/public/testimonial-02.jpg'
import TestimonialImg03 from '@/public/testimonial-03.jpg'
interface Testimonial {
img: StaticImageData
quote: string
name: string
role: string
}
export default function FancyTestimonialsSlider() {
const testimonialsRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState<number>(0)
const [autorotate, setAutorotate] = useState<boolean>(true)
const autorotateTiming: number = 7000
const testimonials: Testimonial[] = [
{
img: TestimonialImg01,
quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
name: 'Jessie J',
role: 'Acme LTD'
},
{
img: TestimonialImg02,
quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
name: 'Nick V',
role: 'Malika Inc.'
},
{
img: TestimonialImg03,
quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
name: 'Amelia W',
role: 'Panda AI'
}
]
const heightFix = () => {
if (testimonialsRef.current && testimonialsRef.current.parentElement) testimonialsRef.current.parentElement.style.height = `${testimonialsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-3xl mx-auto text-center">
{/* Testimonial image */}
<div className="relative h-32">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
<div className="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
className="absolute inset-0 h-full -z-10"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 order-first"
enterFrom="opacity-0 -rotate-[60deg]"
enterTo="opacity-100 rotate-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700"
leaveFrom="opacity-100 rotate-0"
leaveTo="opacity-0 rotate-[60deg]"
beforeEnter={() => heightFix()}
>
<Image className="relative top-11 left-1/2 -translate-x-1/2 rounded-full" src={testimonial.img} width={56} height={56} alt={testimonial.name} />
</Transition>
))}
</div>
</div>
</div>
{/* Text */}
<div className="mb-9 transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={testimonialsRef}>
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 -translate-x-4"
enterTo="opacity-100 translate-x-0"
leave="transition ease-out duration-300 delay-300 absolute"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-4"
beforeEnter={() => heightFix()}
>
<div className="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{testimonial.quote}</div>
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="flex flex-wrap justify-center -m-1.5">
{testimonials.map((testimonial, index) => (
<button
key={index}
className={`inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 ${active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'}`}
onClick={() => { setActive(index); }}
>
<span>{testimonial.name}</span> <span className={`${active === index ? 'text-indigo-200' : 'text-slate-300'}`}>-</span> <span>{testimonial.role}</span>
</button>
))}
</div>
</div>
)
}
Adding the auto-rotate feature
The final step of the tutorial is to add the auto-rotate functionality. Additionally, to ensure an optimal user experience, we want to disable auto-rotation when the user interacts with the buttons.
As you may have already noticed, we defined the autorotate
and autorotateTiming
variables from the beginning. The autorotate
variable determines whether auto-rotation is active or not, while the autorotateTiming
variable sets the time interval between each slide.
To activate the auto-rotate feature, we can simply add a useEffect
hook that triggers whenever there are changes in the active
or autorotate
state variables:
useEffect(() => {
if (!autorotate) return
const interval = setInterval(() => {
setActive(active + 1 === testimonials.length ? 0 : active => active + 1)
}, autorotateTiming)
return () => clearInterval(interval)
}, [active, autorotate])
Finally, to disable auto-rotation when the user interacts with the buttons, we can simply set the autorotate
state variable to false
on the onClick
event:
<button
key={index}
className={`inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 ${active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'}`}
onClick={() => { setActive(index); setAutorotate(false); }}
>
<span>{testimonial.name}</span> <span className={`${active === index ? 'text-indigo-200' : 'text-slate-300'}`}>-</span> <span>{testimonial.role}</span>
</button>
The component is now complete. However, it is currently not reusable because we have defined the content of the testimonials within the component itself. To make the component reusable, some modifications are necessary. Let’s see what needs to be done.
Create a reusable testimonials component
To make the component reusable, we need to move the testimonials’ content to the parent component (in our case, the page.tsx
file). Then, we will pass the testimonials
array as a prop to the FancyTestimonialsSlider
component:
export const metadata = {
title: 'Fancy Testimonials Slider - Cruip Tutorials',
description: 'Page description',
}
import TestimonialImg01 from '@/public/testimonial-01.jpg'
import TestimonialImg02 from '@/public/testimonial-02.jpg'
import TestimonialImg03 from '@/public/testimonial-03.jpg'
import FancyTestimonialsSlider from '@/components/fancy-testimonials-slider'
export default function FancyTestimonialSliderPage() {
const testimonials = [
{
img: TestimonialImg01,
quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
name: 'Jessie J',
role: 'Acme LTD'
},
{
img: TestimonialImg02,
quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
name: 'Nick V',
role: 'Malika Inc.'
},
{
img: TestimonialImg03,
quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
name: 'Amelia W',
role: 'Panda AI'
}
]
return (
<FancyTestimonialsSlider testimonials={testimonials} />
)
}
We also need to update the FancyTestimonialsSlider
component to accept props. To do this, the previously defined function as FancyTestimonialsSlider()
will change to FancyTestimonialsSlider({ testimonials }: { testimonials: Testimonial[] })
.
Finally, we can remove the imported images from the FancyTestimonialsSlider
component since they will be passed as props.
Here is the updated complete component:
'use client'
import { useState, useRef, useEffect } from 'react'
import Image, { StaticImageData } from 'next/image'
import { Transition } from '@headlessui/react'
interface Testimonial {
img: StaticImageData
quote: string
name: string
role: string
}
export default function FancyTestimonialsSlider({ testimonials }: { testimonials: Testimonial[] }) {
const testimonialsRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState<number>(0)
const [autorotate, setAutorotate] = useState<boolean>(true)
const autorotateTiming: number = 7000
useEffect(() => {
if (!autorotate) return
const interval = setInterval(() => {
setActive(active + 1 === testimonials.length ? 0 : active => active + 1)
}, autorotateTiming)
return () => clearInterval(interval)
}, [active, autorotate])
const heightFix = () => {
if (testimonialsRef.current && testimonialsRef.current.parentElement) testimonialsRef.current.parentElement.style.height = `${testimonialsRef.current.clientHeight}px`
}
useEffect(() => {
heightFix()
}, [])
return (
<div className="w-full max-w-3xl mx-auto text-center">
{/* Testimonial image */}
<div className="relative h-32">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
<div className="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
className="absolute inset-0 h-full -z-10"
enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 order-first"
enterFrom="opacity-0 -rotate-[60deg]"
enterTo="opacity-100 rotate-0"
leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700"
leaveFrom="opacity-100 rotate-0"
leaveTo="opacity-0 rotate-[60deg]"
beforeEnter={() => heightFix()}
>
<Image className="relative top-11 left-1/2 -translate-x-1/2 rounded-full" src={testimonial.img} width={56} height={56} alt={testimonial.name} />
</Transition>
))}
</div>
</div>
</div>
{/* Text */}
<div className="mb-9 transition-all duration-150 delay-300 ease-in-out">
<div className="relative flex flex-col" ref={testimonialsRef}>
{testimonials.map((testimonial, index) => (
<Transition
as="div"
key={index}
show={active === index}
enter="transition ease-in-out duration-500 delay-200 order-first"
enterFrom="opacity-0 -translate-x-4"
enterTo="opacity-100 translate-x-0"
leave="transition ease-out duration-300 delay-300 absolute"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-4"
beforeEnter={() => heightFix()}
>
<div className="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{testimonial.quote}</div>
</Transition>
))}
</div>
</div>
{/* Buttons */}
<div className="flex flex-wrap justify-center -m-1.5">
{testimonials.map((testimonial, index) => (
<button
key={index}
className={`inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 ${active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'}`}
onClick={() => { setActive(index); setAutorotate(false); }}
>
<span>{testimonial.name}</span> <span className={`${active === index ? 'text-indigo-200' : 'text-slate-300'}`}>-</span> <span>{testimonial.role}</span>
</button>
))}
</div>
</div>
)
}
We hope you enjoyed this tutorial and learned a thing or two about how simple it is to create enjoyable modern components to enrich your Tailwind CSS landing pages.
If you’re interested in seeing how we developed the same testimonial slider for Alpine.js and Vue, check out the other two parts below: