DEV Community

Cover image for Create a Carousel with Progress Indicators using Tailwind and Vue
Cruip
Cruip

Posted on • Originally published at cruip.com

Create a Carousel with Progress Indicators using Tailwind and Vue

Live Demo / Download

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. 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.

<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>
Enter fullscreen mode Exit fullscreen mode

This structure mirrors the React component 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 for transitions:

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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)