How to Build a Fancy Testimonial Slider with Tailwind CSS and Vue
Tags: tailwindcss, vue, tutorial, webdev
Published: 2023-06-20 09:01:25

####**[Live Demo](https://cruip-tutorials-vue.vercel.app/fancy-testimonials-slider) / [Download](https://github.com/cruip/cruip-tutorials-vue/blob/main/src/components/FancyTestimonialsSlider.vue)**

--

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](https://cruip.com/) (e.g., **Stellar**, a [dark landing page template](https://cruip.com/stellar/) 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:

```ts
  <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:

```html
  <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](https://headlessui.com/) 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:

```html
  <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:

```ts
  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:

```ts
<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:

```tsx
  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:

```ts
  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:

```ts
  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:

```ts
  @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:

```ts
  <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:

```ts
  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:

```ts
  <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](https://cruip.com/tutorials/) section, where we showcase remarkable components and effects that seamlessly complement your projects.