· Updated on

How to Build a Fancy Testimonial Slider with Tailwind CSS and Vue

Fancy testimonial slider preview

Welcome to the third and final part of our series on How to Build a Fancy Testimonial Slider with Tailwind CSS! This post will guide you through the development of a Vue and Tailwind CSS-based fancy testimonial slider featuring comprehensive TypeScript compatibility.

As usual, to get a better idea of how the final outcome will look, check out the live demo or one of our beautiful Tailwind CSS templates (e.g., Stellar, a dark landing page template based on Next.js).

Let’s get started with the tutorial. You can keep your favorite code editor open while you follow along.

Create the structure for the Vue component

To kick things off, let’s create a new file called FancyTestimonialsSlider.vue for our component and add the following code:

<script setup lang="ts">
import { ref } from 'vue'

import TestimonialImg01 from '../assets/testimonial-01.jpg'
import TestimonialImg02 from '../assets/testimonial-02.jpg'
import TestimonialImg03 from '../assets/testimonial-03.jpg'

const active = ref<number>(0)
const autorotate = ref<boolean>(true)
const autorotateTiming = ref<number>(7000)

interface Testimonial {
  img: string
  quote: string
  name: string
  role: string
}

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 be able to select between formats.ture responses is a game-changer.",
    name: 'Jessie J',
    role: 'Ltd Head of Product'
  },
  {
    img: TestimonialImg02,
    quote: "I have been using this product for a few weeks now and I am blown away by the results. My skin looks visibly brighter and smoother, and I have received so many compliments on my complexion.",
    name: 'Mark Luk',
    role: 'Spark Founder & CEO'
  },
  {
    img: TestimonialImg03,
    quote: "As a busy professional, I don't have a lot of time to devote to working out. But with this fitness program, I have seen amazing results in just a few short weeks. The workouts are efficient and effective.",
    name: 'Jeff Kahl',
    role: 'Appy Product Lead'
  }
]
</script>

<template>
  <div class="w-full max-w-3xl mx-auto text-center">
    <!-- ... -->
  </div>
</template>

Firstly, note that we are using the new syntax setup of Vue 3, which allows us to use the Composition API inside the script tag without needing to use the export default syntax.

Next, we have imported the testimonials’ images and defined the variables active, autorotate, and autorotateTiming, which we have already used in the previous HTML and React components.

To ensure reactivity for these variables, we have used the ref functionfrom Vue 3’s Composition API. This allows us to treat the variables as reactive without using the data object.

Additionally, we’ve defined the testimonials array that contains the properties for each testimonial, including the image, quote, name, and role.

Lastly, since we are adopting TypeScript, we’ve defined the Testimonial interface to specify the type of each testimonial property.

Great! Now, let’s move on to constructing the HTML structure of our component within the template tag:

<template>
  <div class="w-full max-w-3xl mx-auto text-center">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="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 class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">

            <template :key="index" v-for="(testimonial) in testimonials">
              <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name" />
            </template>

        </div>
      </div>
    </div>
    <!-- Text -->
    <div class="mb-9 transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col">
        <template :key="index" v-for="(testimonial) in testimonials">
          <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{{ testimonial.quote }}</div>
        </template>
      </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
      <template :key="index" v-for="(testimonial, index) in testimonials">
        <button
          class="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"
          :class="active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'"
          @click="active = index"
        >
          <span>{{ testimonial.name }}</span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span>{{ testimonial.role }}</span>
        </button>
      </template>
    </div>
  </div>
</template>

While in React we emploied the map() method to iterate over the testimonials array, in Vue 3 we have used a template tag with the v-for attribute to render each testimonial.

To add dynamic behavior to the buttons, we’ve used the :class directive. This allows us to apply different classes to the buttons based on whether they represent the active testimonial or not. The active state is denoted by the classes bg-indigo-500 text-white shadow-indigo-950/10, while the inactive state uses the classes bg-white hover:bg-indigo-100 text-slate-900.

To handle user interaction, we’ve added an @click event to each button, which updates the active testimonial index.

We’re making solid progress, but we still need to show only the active testimonial and define the fancy transitions, which are a visually appealing feature of this component.

Show only the active testimonial and define transitions

We will accomplish these two tasks in a single step by using a transition component. We’ll use the Headless UI library instead of Vue 3’s built-in Transition component. If you’ve followed our previous tutorial on creating a video modal component, you might already be familiar with this preference.

Let’s start by installing Headless UI using the command npm install @headlessui/react@latest.

Once installed, we can import the TransitionRoot component and wrap the image and text of the active testimonial within it:

<script setup lang="ts">
import { ref } from 'vue'
import { TransitionRoot } from '@headlessui/vue'

import TestimonialImg01 from '../assets/testimonial-01.jpg'
import TestimonialImg02 from '../assets/testimonial-02.jpg'
import TestimonialImg03 from '../assets/testimonial-03.jpg'

const active = ref<number>(0)
const autorotate = ref<boolean>(true)
const autorotateTiming = ref<number>(7000)

interface Testimonial {
  img: string
  quote: string
  name: string
  role: string
}

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 be able to select between formats.ture responses is a game-changer.",
    name: 'Jessie J',
    role: 'Ltd Head of Product'
  },
  {
    img: TestimonialImg02,
    quote: "I have been using this product for a few weeks now and I am blown away by the results. My skin looks visibly brighter and smoother, and I have received so many compliments on my complexion.",
    name: 'Mark Luk',
    role: 'Spark Founder & CEO'
  },
  {
    img: TestimonialImg03,
    quote: "As a busy professional, I don't have a lot of time to devote to working out. But with this fitness program, I have seen amazing results in just a few short weeks. The workouts are efficient and effective.",
    name: 'Jeff Kahl',
    role: 'Appy Product Lead'
  }
]
</script>

<template>
  <div class="w-full max-w-3xl mx-auto text-center">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="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 class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">

            <template :key="index" v-for="(testimonial, index) in testimonials">
              <TransitionRoot
                :show="active === index"
                class="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]"
              >
                <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name" />
              </TransitionRoot>
            </template>

        </div>
      </div>
    </div>
    <!-- Text -->
    <div class="mb-9 transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col" ref="testimonialsRef">

        <template :key="index" v-for="(testimonial, index) in testimonials">
          <TransitionRoot
            :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 class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{{ testimonial.quote }}</div>
          </TransitionRoot>          
        </template>

      </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
      <template :key="index" v-for="(testimonial, index) in testimonials">
        <button
          class="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"
          :class="active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'"
          @click="active = index"
        >
          <span>{{ testimonial.name }}</span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span>{{ testimonial.role }}</span>
        </button>
      </template>
    </div>
  </div>
</template>

By using the :show directive, we can control which testimonial is currently displayed while hiding the others. We’ve also applied Tailwind CSS classes to define entrance and exit animations.

As a result, when transitioning between testimonials, the text will gracefully fade in from the left, and the image will fade in with a clockwise rotation.

Improving UX during transitions

Now, let’s make sure to provide an optimal user experience. As we have seen before, if one testimonial has more text than the others, the height of the testimonial will abruptly change during the transition, resulting in a less pleasant effect.

To prevent this from happening, we will add a method called heightFix() to our component, which calculates the height of the current testimonial and applies it to the parent element:

const heightFix = () => {  
  setTimeout(() => {
    if (testimonialsRef.value && testimonialsRef.value.parentElement) testimonialsRef.value.parentElement.style.height = `${testimonialsRef.value.clientHeight}px`
  }, 1)
}

The heightFix() method is fired when the @before-enter event emitted by the transition component, just like this:

<template :key="index" v-for="(testimonial, index) in testimonials">
  <TransitionRoot
    :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"
    @before-enter="heightFix()"
  >
    <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{{ testimonial.quote }}</div>
  </TransitionRoot>          
</template>

Enabling autorotate functionality on component mount

Now, let’s add a final touch to our testimonial slider by enabling automatic rotation between testimonials. We want them to automatically rotate ewith a 7-second interval.

To do this, we will use Vue 3’s onMounted() hook, which allows us to execute code when the component is mounted:

let interval: number
  
onMounted(() => {
  if (!autorotate.value) return
  interval = setInterval(() => {
    active.value = active.value + 1 === testimonials.length ? 0 : active.value + 1
  }, autorotateTiming.value)
})

We also need to ensure that the interval is cleared when the component is unmounted. We we will use the onUnmounted() hook for that:

onUnmounted(() => clearInterval(interval))

Finally, we want to turn off the automatic rotation when the user interacts with the buttons. We’ll create a method called stopAutorotate() for this purpose. This method changes the autorotate variable from true to false and clears the interval:

const stopAutorotate = () => {
  autorotate.value = false
  clearInterval(interval)
}

To activate this method, we’ll simply call it when a user clicks on one of the buttons:

@click="active = index; stopAutorotate();"

Et voilà! We have created an advanced testimonial component that provides an optimal user experience and visually appealing animations.

But there is still something we can do to improve it. Currently, using the component requires defining the testimonial properties directly within the component itself, limiting its flexibility. That’s why we want to make our component reusable.

Making the testimonial component reusable

Here’s the plan: we’ll transfer the testimonials array to the parent component, which in this case is FancyTestimonialSliderPage.vue. Then, we will pass the array to the component through the :testimonials prop:

<script setup lang="ts">
import TestimonialImg01 from '../assets/testimonial-01.jpg'
import TestimonialImg02 from '../assets/testimonial-02.jpg'
import TestimonialImg03 from '../assets/testimonial-03.jpg'
import FancyTestimonialsSlider from '../components/FancyTestimonialsSlider.vue'

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 be able to select between formats.ture responses is a game-changer.",
    name: 'Jessie J',
    role: 'Ltd Head of Product'
  },
  {
    img: TestimonialImg02,
    quote: "I have been using this product for a few weeks now and I am blown away by the results. My skin looks visibly brighter and smoother, and I have received so many compliments on my complexion.",
    name: 'Mark Luk',
    role: 'Spark Founder & CEO'
  },
  {
    img: TestimonialImg03,
    quote: "As a busy professional, I don't have a lot of time to devote to working out. But with this fitness program, I have seen amazing results in just a few short weeks. The workouts are efficient and effective.",
    name: 'Jeff Kahl',
    role: 'Appy Product Lead'
  }
]
</script>

<template>
  <FancyTestimonialsSlider :testimonials="testimonials" />
</template>

Of course, we will also need to modify the component we have created so that it can receive testimonial data from the outside. To do this, we will use the defineProps() function of Vue 3, which allows us to define the props of a component as follows:

const props = defineProps<{
  testimonials: Testimonial[]
}>()

const testimonials = props.testimonials

Finally, now that our testimonials are defined in the parent component, we can safely remove the image imports in our testimonial component. We no longer need them since the parent component takes care of that.

And here we have our reusable testimonial component:

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { TransitionRoot } from '@headlessui/vue'

const testimonialsRef = ref<HTMLCanvasElement | null>(null)
const active = ref<number>(0)
const autorotate = ref<boolean>(true)
const autorotateTiming = ref<number>(7000)
let interval: number

interface Testimonial {
  img: string
  quote: string
  name: string
  role: string
}

const props = defineProps<{
  testimonials: Testimonial[]
}>()

const testimonials = props.testimonials

const heightFix = () => {  
  setTimeout(() => {
    if (testimonialsRef.value && testimonialsRef.value.parentElement) testimonialsRef.value.parentElement.style.height = `${testimonialsRef.value.clientHeight}px`
  }, 1)
}

const stopAutorotate = () => {
  autorotate.value = false
  clearInterval(interval)
}

onMounted(() => {      
  if (!autorotate.value) return
  interval = setInterval(() => {
    active.value = active.value + 1 === testimonials.length ? 0 : active.value + 1
  }, autorotateTiming.value)
})

onUnmounted(() => clearInterval(interval))
</script>

<template>
  <div class="w-full max-w-3xl mx-auto text-center">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="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 class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">

            <template :key="index" v-for="(testimonial, index) in testimonials">
              <TransitionRoot
                :show="active === index"
                class="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]"
              >
                <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name" />
              </TransitionRoot>
            </template>

        </div>
      </div>
    </div>
    <!-- Text -->
    <div class="mb-9 transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col" ref="testimonialsRef">

        <template :key="index" v-for="(testimonial, index) in testimonials">
          <TransitionRoot
            :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"
            @before-enter="heightFix()"
          >
            <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">{{ testimonial.quote }}</div>
          </TransitionRoot>          
        </template>

      </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
      <template :key="index" v-for="(testimonial, index) in testimonials">
        <button
          class="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"
          :class="active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'"
          @click="active = index; stopAutorotate();"
        >
          <span>{{ testimonial.name }}</span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span>{{ testimonial.role }}</span>
        </button>
      </template>
    </div>
  </div>
</template>

And there you have it! We have reached the end of this tutorial and our mini-series on creating a fancy testimonial slider with Tailwind CSS. If you found this post helpful, don’t miss out on the previous parts covering Alpine.js and Next.js. Additionally, feel free to explore our Tailwind CSS tutorials section, where we showcase remarkable components and effects that seamlessly complement your projects.