Create a Carousel with Progress Indicators using Tailwind and 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. 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>
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>
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>
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>
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>
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>
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: