Create a Carousel with Progress Indicators using Tailwind and Vue
Tags: webdev, tutorial, tailwindcss, vue
Published: 2024-01-09 11:36:39

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

Welcome to the third and final part of this series. In the previous articles, we covered **creating a carousel with progress indicators** using HTML and React. Now, it's time to dive into **Vue**. At the end of the tutorial, we will also see how to [make our component reusable](#make-the-component-reusable). That way, we can use it multiple times in the same project. Let's get started!

Create a file for the component
-------------------------------

Let's create a file named `ProgressSlider.vue` in the `components` folder of our app. In this file, we'll define a **Single-File Component (SFC)** using the **Composition API** and **TypeScript**.

```html
<script setup lang="ts">
import SilderImg01 from '../assets/ps-image-01.png'
import SilderImg02 from '../assets/ps-image-02.png'
import SilderImg03 from '../assets/ps-image-03.png'
import SilderImg04 from '../assets/ps-image-04.png'
import SilderIcon01 from '../assets/ps-icon-01.svg'
import SilderIcon02 from '../assets/ps-icon-02.svg'
import SilderIcon03 from '../assets/ps-icon-03.svg'
import SilderIcon04 from '../assets/ps-icon-04.svg'

const items = [
  {
    img: SilderImg01,
    desc: 'Omnichannel',
    buttonIcon: SilderIcon01,
  },
  {
    img: SilderImg02,
    desc: 'Multilingual',
    buttonIcon: SilderIcon02,
  },
  {
    img: SilderImg03,
    desc: 'Interpolate',
    buttonIcon: SilderIcon03,
  },
  {
    img: SilderImg04,
    desc: 'Enriched',
    buttonIcon: SilderIcon04,
  },
]
</script>

<template>
  <div class="w-full max-w-5xl mx-auto text-center">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col">
  
        <template :key="index" v-for="item in items">
          <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
        </template>
  
      </div>
    </div>
    <!-- Buttons -->
    <div class="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
      <template :key="index" v-for="item in items">
        <button class="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group">
          <span class="text-center flex flex-col items-center">
            <span class="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
              <img :src="item.buttonIcon" :alt="item.desc">
            </span>
            <span class="block text-sm font-medium text-slate-900 mb-2">{{ item.desc }}</span>
            <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
              <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]" style="width: 0%"></span>
            </span>
          </span>          
        </button>
      </template>
    </div>
  </div>
</template>
```

This structure mirrors the [React component](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-nextjs/) we crafted earlier in this series. While in React we used the `map` function to iterate over the array of objects, for Vue we leveraged the native `v-for` directive. The above code returns four images stacked on top of each other, along with buttons displaying their respective names and progress bars at the page's bottom.

Add transitions and define the active element
---------------------------------------------

Now that we've set up the component structure, we can add transitions and define the active element. Once again, we'll use the [Headless UI library](https://headlessui.com/vue/transition) for transitions:

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

import SilderImg01 from '../assets/ps-image-01.png'
// ... other image imports

const active = ref<number>(0)

const items = [
  // ... items array
]
</script>

<template>
  <div class="w-full max-w-5xl mx-auto text-center">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col">
  
          <template :key="index" v-for="(item, index) in items">
            <TransitionRoot
              :show="active === index"
              enter="transition ease-in-out duration-500 delay-200 order-first"
              enterFrom="opacity-0 scale-105"
              enterTo="opacity-100 scale-100"
              leave="transition ease-in-out duration-300 absolute"
              leaveFrom="opacity-100 scale-100"
              leaveTo="opacity-0 scale-95"
            >
              <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
            </TransitionRoot>          
          </template>
  
      </div>
    </div>
    <!-- Buttons -->
    <div class="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
      <template :key="index" v-for="(item, index) in items">
          <button
            class="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
            @click="active = index"
          >
          <span class="text-center flex flex-col items-center">
            <span class="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
              <img :src="item.buttonIcon" :alt="item.desc">
            </span>
            <span class="block text-sm font-medium text-slate-900 mb-2">{{ item.desc }}</span>
            <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
              <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]" style="width: 0%"></span>
            </span>
          </span>          
        </button>
      </template>
    </div>
  </div>
</template>
```

In the code above, we first imported the `TransitionRoot` component from Headless UI. After that, we declared a `ref` variable named `active` and set its initial value to `0`. This variable will determine which element is active. Next, we wrapped the `img` element with the `TransitionRoot` component and used the `v-show` directive to display the element only when `active` is equal to the index of that element. Lastly, we attached a `click` event to the button, which updates the `active` variable to the index of the clicked element.

Add automatic rotation functionality
------------------------------------

Now we need to add an autoplay function that will make the carousel start spinning on its own as soon as the page loads. It should rotate at regular intervals of 5 seconds. We'll reuse the logic from the [previously created component with Alpine.js](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-alpinejs/#integrating-autoplay):

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

import SilderImg01 from '../assets/ps-image-01.png'
// ... other image imports

const duration: number = 5000
const frame = ref<number>(0)
const firstFrameTime = ref(performance.now())
const active = ref<number>(0)

const items = [
// ... items array
]

const startAnimation = () => {
  firstFrameTime.value = performance.now()
  frame.value = requestAnimationFrame(animate)
}

const animate = (now: number) => {
  let timeDifference = now - firstFrameTime.value
  let timeFraction = Math.max(0, timeDifference) / duration
  if (timeFraction <= 1) {
    frame.value = requestAnimationFrame(animate)
  } else {
    timeFraction = 1
    active.value = (active.value + 1) % items.length
  }
}

onMounted(() => startAnimation())

onUnmounted(() => cancelAnimationFrame(frame.value))

watch(active, () => {
  cancelAnimationFrame(frame.value)
  startAnimation()
})
</script>

<template>
  <div class="w-full max-w-5xl mx-auto text-center">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col">
  
          <template :key="index" v-for="(item, index) in items">
            <TransitionRoot
              :show="active === index"
              enter="transition ease-in-out duration-500 delay-200 order-first"
              enterFrom="opacity-0 scale-105"
              enterTo="opacity-100 scale-100"
              leave="transition ease-in-out duration-300 absolute"
              leaveFrom="opacity-100 scale-100"
              leaveTo="opacity-0 scale-95"
            >
              <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
            </TransitionRoot>          
          </template>
  
      </div>
    </div>
    <!-- Buttons -->
    <div class="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
      <template :key="index" v-for="(item, index) in items">
          <button
            class="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
            @click="active = index"
          >
          <span class="text-center flex flex-col items-center">
            <span class="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
              <img :src="item.buttonIcon" :alt="item.desc">
            </span>
            <span class="block text-sm font-medium text-slate-900 mb-2">{{ item.desc }}</span>
            <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
              <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]" style="width: 0%"></span>
            </span>
          </span>          
        </button>
      </template>
    </div>
  </div>
</template>
```

We included a variable called `duration` to determine how long each slide will be displayed. Then, we aadded a `frame` variable and set it to `0`. This variable will keep track of the current frame. Additionally, we created another variable called `firstFrameTime` and set it to the current time using `performance.now()`. This variable will help us calculate the time passed since the first frame. To start the animation, we used the `onMounted` lifecycle hook to call the `startAnimation` method. This method will also be triggered whenever the `active` variable changes using the `watch` method. As a result, we have a carousel that automatically switches images every 5 seconds, while still allowing manual navigation between slides.

Provide buttons with progress indicators
----------------------------------------

The last step is to provide buttons that show the progress. To save time, we have already taken care of the integration:

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

import SilderImg01 from '../assets/ps-image-01.png'
// ... other image imports

const duration: number = 5000
const frame = ref<number>(0)
const firstFrameTime = ref(performance.now())
const active = ref<number>(0)
const progress = ref<number>(0)

const items = [
  // ... items array
]

const startAnimation = () => {
  firstFrameTime.value = performance.now()
  frame.value = requestAnimationFrame(animate)
}

const animate = (now: number) => {
  let timeDifference = now - firstFrameTime.value
  let timeFraction = Math.max(0, timeDifference) / duration
  if (timeFraction <= 1) {
    progress.value = timeFraction * 100
    frame.value = requestAnimationFrame(animate)
  } else {
    timeFraction = 1
    progress.value = 0
    active.value = (active.value + 1) % items.length
  }
}

onMounted(() => startAnimation())

onUnmounted(() => cancelAnimationFrame(frame.value))

watch(active, () => {
  cancelAnimationFrame(frame.value)
  startAnimation()
})
</script>

<template>
  <div class="w-full max-w-5xl mx-auto text-center">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col">
  
          <template :key="index" v-for="(item, index) in items">
            <TransitionRoot
              :show="active === index"
              enter="transition ease-in-out duration-500 delay-200 order-first"
              enterFrom="opacity-0 scale-105"
              enterTo="opacity-100 scale-100"
              leave="transition ease-in-out duration-300 absolute"
              leaveFrom="opacity-100 scale-100"
              leaveTo="opacity-0 scale-95"
            >
              <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
            </TransitionRoot>          
          </template>
  
      </div>
    </div>
    <!-- Buttons -->
    <div class="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
      <template :key="index" v-for="(item, index) in items">
        <button
          class="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
          @click="active = index"
        >
          <span class="text-center flex flex-col items-center" :class="active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'">
            <span class="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
              <img :src="item.buttonIcon" :alt="item.desc">
            </span>
            <span class="block text-sm font-medium text-slate-900 mb-2">{{ item.desc }}</span>
            <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" :aria-valuenow="active === index ? progress : 0" aria-valuemin="0" aria-valuemax="100">
              <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]" :style="`${active === index ? `width: ${progress}%` : 'width: 0%'}`"></span>
            </span>
          </span>          
        </button>
      </template>
    </div>
  </div>
</template>
```

We defined a `ref` variable named `progress` and set it to `0`. This variable will determine the progress of each slide on a scale from `0` to `100`. This variable is needed for determining both the width of the progress bar and the dynamic value of the `aria-valuenow` attribute of every progress bar. With this final integration, the component is now complete and can be utilized in any Vue project based on Tailwind CSS. However, if you wish to use it multiple times within the same app, it may make sense to transform it into a reusable component.

Make the component reusable
---------------------------

Right now, we have the array of objects defined within the component itself. In order to make the component reusable, we should transfer the array to the parent component and pass it as props to the component. As a result, the component we've been working on will be modified as follows:

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

const duration: number = 5000
const itemsRef = ref<HTMLCanvasElement | null>(null)
const frame = ref<number>(0)
const firstFrameTime = ref(performance.now())
const active = ref<number>(0)
const progress = ref<number>(0)

interface Item {
  img: string
  desc: string
  buttonIcon: string
}

const props = defineProps<{
  items: Item[]
}>()

const items = props.items

const startAnimation = () => {
  firstFrameTime.value = performance.now()
  frame.value = requestAnimationFrame(animate)
}

const animate = (now: number) => {
  let timeDifference = now - firstFrameTime.value
  let timeFraction = Math.max(0, timeDifference) / duration
  if (timeFraction <= 1) {    
    progress.value = timeFraction * 100
    frame.value = requestAnimationFrame(animate)
  } else {    
    timeFraction = 1
    progress.value = 0
    active.value = (active.value + 1) % items.length
  }
}

const heightFix = async () => {
  await nextTick()
  if (itemsRef.value && itemsRef.value.parentElement) itemsRef.value.parentElement.style.height = `${itemsRef.value.clientHeight}px`
}

onMounted(() => startAnimation())

onUnmounted(() => cancelAnimationFrame(frame.value))

watch(active, () => {
  cancelAnimationFrame(frame.value)
  startAnimation()
})
</script>

<template>
  <div class="w-full max-w-5xl mx-auto text-center">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
      <div class="relative flex flex-col" ref="itemsRef">
  
        <template :key="index" v-for="(item, index) in items">
          <TransitionRoot
            :show="active === index"
            enter="transition ease-in-out duration-500 delay-200 order-first"
            enterFrom="opacity-0 scale-105"
            enterTo="opacity-100 scale-100"
            leave="transition ease-in-out duration-300 absolute"
            leaveFrom="opacity-100 scale-100"
            leaveTo="opacity-0 scale-95"
            @before-enter="heightFix()"
          >
            <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
          </TransitionRoot>          
        </template>
  
      </div>
    </div>
    <!-- Buttons -->
    <div class="max-w-xs sm:max-w-sm md:max-w-3xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
      <template :key="index" v-for="(item, index) in items">
        <button
          class="p-2 rounded focus:outline-none focus-visible:ring focus-visible:ring-indigo-300 group"
          @click="active = index"
        >
          <span class="text-center flex flex-col items-center" :class="active === index ? '' : 'opacity-50 group-hover:opacity-100 group-focus:opacity-100 transition-opacity'">
            <span class="flex items-center justify-center relative w-9 h-9 rounded-full bg-indigo-100 mb-2">
              <img :src="item.buttonIcon" :alt="item.desc">
            </span>
            <span class="block text-sm font-medium text-slate-900 mb-2">{{ item.desc }}</span>
            <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" :aria-valuenow="active === index ? progress : 0" aria-valuemin="0" aria-valuemax="100">
              <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]" :style="`${active === index ? `width: ${progress}%` : 'width: 0%'}`"></span>
            </span>
          </span>          
        </button>
      </template>
    </div>
  </div>
</template>
```

We created a `props` variable with the type `defineProps` that takes an object containing a `items` property. We also declared the `Item` TypeScript interface to define the structure of each object in the array. Now, we can define the array of objects in the parent component and pass it as props to the component:

```html
<script setup lang="ts">
import SilderImg01 from '../assets/ps-image-01.png'
import SilderImg02 from '../assets/ps-image-02.png'
import SilderImg03 from '../assets/ps-image-03.png'
import SilderImg04 from '../assets/ps-image-04.png'
import SilderIcon01 from '../assets/ps-icon-01.svg'
import SilderIcon02 from '../assets/ps-icon-02.svg'
import SilderIcon03 from '../assets/ps-icon-03.svg'
import SilderIcon04 from '../assets/ps-icon-04.svg'
import ProgressSlider from '../components/ProgressSlider.vue'

const items = [
  {
    img: SilderImg01,
    desc: 'Omnichannel',
    buttonIcon: SilderIcon01,
  },
  {
    img: SilderImg02,
    desc: 'Multilingual',
    buttonIcon: SilderIcon02,
  },
  {
    img: SilderImg03,
    desc: 'Interpolate',
    buttonIcon: SilderIcon03,
  },
  {
    img: SilderImg04,
    desc: 'Enriched',
    buttonIcon: SilderIcon04,
  },
]
</script>

<template>
  <main class="relative min-h-screen flex flex-col justify-center bg-slate-50 overflow-hidden">
    <div class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
      <div class="flex justify-center">
        <ProgressSlider :items="items" />
      </div>
    </div>
  </main>
</template>
```

Conclusions
-----------

We've reached the end of this tutorial in three parts. If you want to learn how to build this component with **Alpine.js** or **Next.js**, check out the first and second parts:

*   [Create a Carousel with Progress Indicators using Tailwind and Alpine.js](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-alpinejs/)
*   [Create a Carousel with Progress Indicators using Tailwind and Next.js](https://cruip.com/create-a-carousel-with-progress-indicators-using-tailwind-and-nextjs/)