· Updated on

Create a Carousel with Progress Indicators using Tailwind and Alpine.js

A preview of the carousel component that we're going to build with Tailwind and Alpine.js

A landing page that lives up to its name must condense information in a clear and effective way. When it comes to show a product’s features using images, there’s a risk of overwhelming the user with a pletohora of visual content. That’s why a carousel of images is often the go-to solution in such cases.

The carousel is basically a picture slideshow with some buttons that allow users to navigate between images. In this tutorial, we will see how to create such a component paired with a set of buttons, each of which includes a progress indicator showing the elapsed time for each image. For this example, we took inspiration from the impressive design of Homerun and built our version using Tailwind CSS and Alpine.js.

Let’s get started!

Creating the structure

As we always do, we’ve prepared a basic HTML structure using Tailwind classes to get you started:

<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">
            <div>
                <img class="rounded-xl" src="./ps-image-01.png" width="1024" height="576" alt="Omnichannerl">
            </div>
            <!-- ... more images ... -->
        </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">
        <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="./ps-icon-01.svg" alt="Omnichannel">
                </span>
                <span class="block text-sm font-medium text-slate-900 mb-2">Omnichannel</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]"></span>
                </span>
            </span>
        </button>
        <!-- ... more buttons ... -->
    </div>
</div>

For simplicity, we’ve included a single image with its corresponding button for now. This draft will be our canvas for dynamically defining the carousel elements using an object.

Defining a JS object with items

In this step, we’ll componentize our carousel. Instead of replicating HTML code for each image and button, we’ll define an object containing the elements. Then, we’ll use an x-for loop to iterate over each element and render it.

This way, we can add or remove elements from the carousel by simply modifying the JS object without touching the HTML. Additionally, we’ll have code that is easier to read and maintain.

Let’s start by inserting a <script> tag immediately after the HTML we showed you in the previous paragraph:

<div class="w-full max-w-5xl mx-auto text-center" x-data="carousel">

    <!-- ... component code ... -->

</div>

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('carousel', () => ({
            items: [
                {
                    img: 'ps-image-01.png',
                    desc: 'Omnichannel',
                    buttonIcon: 'ps-icon-01.svg',
                },
                {
                    img: 'ps-image-02.png',
                    desc: 'Multilingual',
                    buttonIcon: 'ps-icon-02.svg',
                },
                {
                    img: 'ps-image-03.png',
                    desc: 'Interpolate',
                    buttonIcon: 'ps-icon-03.svg',
                },
                {
                    img: 'ps-image-04.png',
                    desc: 'Enriched',
                    buttonIcon: 'ps-icon-04.svg',
                },                                
            ],                         
        }))
    })
</script>

Here, we’ve initialized Alpine.js and used Alpine.data(...) to define a carousel object with an array of items. Each item contains information about every carousel elements. Using the x-data="carousel" directive, HTML gains access to this information.

Now, let’s see how to iterate over each element of the items array and render them in the DOM using the x-for directive:

<div class="w-full max-w-5xl mx-auto text-center" x-data="carousel">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
        <div class="relative flex flex-col">
            <template x-for="(item, index) in items" :key="index">
                <div>
                    <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
                </div>
            </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 x-for="(item, index) in items" :key="index">
            <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" x-text="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]"></span>
                    </span>
                </span>
            </button>
        </template>
    </div>
</div>

Defining the active element

So far, we have created a component with four images stacked on top of each other, each with its corresponding button. Next, let’s define the currently active element:

<div class="w-full max-w-5xl mx-auto text-center" x-data="carousel">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
        <div class="relative flex flex-col">
            <template x-for="(item, index) in items" :key="index">
                <div x-show="active === index">
                    <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
                </div>
            </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 x-for="(item, index) in items" :key="index">
            <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" x-text="item.desc"></span>
                    <span class="block relative w-full bg-slate-200 h-1 rounded-full" role="progressbar" aria-valuemin="0" aria-valuemax="100">
                        <span class="absolute inset-0 bg-indigo-500 rounded-[inherit]"></span>
                    </span>
                </span>
            </button>
        </template>
    </div>
</div>
<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('carousel', () => ({
            active: 0,
            items: [
                {
                    img: 'ps-image-01.png',
                    desc: 'Omnichannel',
                    buttonIcon: 'ps-icon-01.svg',
                },
                {
                    img: 'ps-image-02.png',
                    desc: 'Multilingual',
                    buttonIcon: 'ps-icon-02.svg',
                },
                {
                    img: 'ps-image-03.png',
                    desc: 'Interpolate',
                    buttonIcon: 'ps-icon-03.svg',
                },
                {
                    img: 'ps-image-04.png',
                    desc: 'Enriched',
                    buttonIcon: 'ps-icon-04.svg',
                },                                
            ],                         
        }))
    })
</script>

In the code above, we have defined an active property containing the index of the active element – set to 0 by default. Futhermore, we’ve added an x-show on the div that holds the image. This ensures that only the image with the same index as the active element is shown.

Next, we have added a @click handler on the button, updating the active element’s index setting. By doing this, when the user clicks on a button, the corresponding image is displayed.

Finally, we have used :class directives to dynamically change the button styles based on their current status.

The result is a functional carousel enabling users to navigate between images through button clicks. However, transitions between images and the progress indicator are yet to be implemented.

Adding transitions

At this point, let’s implement transitions between images. To do this, we’ll use the x-transition directives from Alpine.js directly on the HTML element:

<div class="transition-all duration-150 delay-300 ease-in-out">
    <div class="relative flex flex-col">
        <template x-for="(item, index) in items" :key="index">
            <div
                x-show="active === index"
                x-transition:enter="transition ease-in-out duration-500 delay-200 order-first"
                x-transition:enter-start="opacity-0 scale-105"
                x-transition:enter-end="opacity-100 scale-100"
                x-transition:leave="transition ease-in-out duration-300 absolute"
                x-transition:leave-start="opacity-100 scale-100"
                x-transition:leave-end="opacity-0 scale-95"
            >
                <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
            </div>
        </template>
    </div>
</div>

With these directives and the use of Tailwind classes, both entering and leaving images appear with a subtle scale-down effect. Feel free to customize the transition effect by using different Tailwind classes. You can take inspiration from other components like the testimonials carousel or animated tabs covered in our previous tutorials.

Integrating autoplay

Now that all the transitions are done, let’s proceed to the next phase: making the slides automatically progress every 5 seconds.

Instead of using the usual techniques like setTimeout or setInterval, we’ll opt for requestAnimationFrame. This method is more efficient than the others because it automatically synchronizes with the browser’s rendering cycle, ensuring optimal performance.

To save time, we’ll directly show you the integration of requestAnimationFrame into our component:

<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('carousel', () => ({
            duration: 5000,
            active: 0,
            progress: 0,
            firstFrameTime: 0,
            items: [
                {
                    img: 'ps-image-01.png',
                    desc: 'Omnichannel',
                    buttonIcon: 'ps-icon-01.svg',
                },
                {
                    img: 'ps-image-02.png',
                    desc: 'Multilingual',
                    buttonIcon: 'ps-icon-02.svg',
                },
                {
                    img: 'ps-image-03.png',
                    desc: 'Interpolate',
                    buttonIcon: 'ps-icon-03.svg',
                },
                {
                    img: 'ps-image-04.png',
                    desc: 'Enriched',
                    buttonIcon: 'ps-icon-04.svg',
                },                                
            ],
            init() {
                this.startAnimation()
                this.$watch('active', callback => {
                    cancelAnimationFrame(this.frame)
                    this.startAnimation()
                })
            },
            startAnimation() {
                this.progress = 0
                this.$nextTick(() => {
                    this.firstFrameTime = performance.now()
                    this.frame = requestAnimationFrame(this.animate.bind(this))
                })
            },
            animate(now) {
                let timeFraction = (now - this.firstFrameTime) / this.duration
                if (timeFraction <= 1) {
                    this.progress = timeFraction * 100
                    this.frame = requestAnimationFrame(this.animate.bind(this))
                } else {
                    timeFraction = 1
                    this.active = (this.active + 1) % this.items.length
                }
            },            
        }))
    })
</script>

In the code above, we have defined additional properties such as duration, progress, and firstFrameTime. The init method runs when the component is initialised, and calls startAnimation that commences the animation on page load and whenever the active property changes.

Finally, the animate method, invoked by startAnimation, calculates the animation progress and updates the active property upon completion.

Integrating progress indicators

With the latest integration, we now have a progress variable storing the animation progress as a value from 0 to 100. This variable will allow us to animate the progress bar of each button, indicating the time each image remains on-screen. Let’s see how to integrate it into HTML:

<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" x-text="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>

Great! The component is now fully functional. As a final integration, let’s add a heightFix method to ensure the carousel maintains the correct height, especially useful when dealing with images of varying sizes. Here is the final code:

<div class="w-full max-w-5xl mx-auto text-center" x-data="slider">
    <!-- Item image -->
    <div class="transition-all duration-150 delay-300 ease-in-out">
        <div class="relative flex flex-col" x-ref="items">
            <!-- Alpine.js template for items: https://github.com/alpinejs/alpine#x-for -->
            <template x-for="(item, index) in items" :key="index">
                <div
                    x-show="active === index"
                    x-transition:enter="transition ease-in-out duration-500 delay-200 order-first"
                    x-transition:enter-start="opacity-0 scale-105"
                    x-transition:enter-end="opacity-100 scale-100"
                    x-transition:leave="transition ease-in-out duration-300 absolute"
                    x-transition:leave-start="opacity-100 scale-100"
                    x-transition:leave-end="opacity-0 scale-95"
                >
                    <img class="rounded-xl" :src="item.img" width="1024" height="576" :alt="item.desc">
                </div>
            </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">
        <!-- Alpine.js template for buttons: https://github.com/alpinejs/alpine#x-for -->
        <template x-for="(item, index) in items" :key="index">
            <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" x-text="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>
<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('slider', () => ({
            duration: 5000,
            active: 0,
            progress: 0,
            firstFrameTime: 0,
            items: [
                {
                    img: 'ps-image-01.png',
                    desc: 'Omnichannel',
                    buttonIcon: 'ps-icon-01.svg',
                },
                {
                    img: 'ps-image-02.png',
                    desc: 'Multilingual',
                    buttonIcon: 'ps-icon-02.svg',
                },
                {
                    img: 'ps-image-03.png',
                    desc: 'Interpolate',
                    buttonIcon: 'ps-icon-03.svg',
                },
                {
                    img: 'ps-image-04.png',
                    desc: 'Enriched',
                    buttonIcon: 'ps-icon-04.svg',
                },                                
            ],
            init() {
                this.startAnimation()
                this.$watch('active', callback => {
                    cancelAnimationFrame(this.frame)
                    this.startAnimation()
                })
            },
            startAnimation() {
                this.progress = 0
                this.$nextTick(() => {
                    this.heightFix()
                    this.firstFrameTime = performance.now()
                    this.frame = requestAnimationFrame(this.animate.bind(this))
                })
            },
            animate(now) {
                let timeFraction = (now - this.firstFrameTime) / this.duration
                if (timeFraction <= 1) {
                    this.progress = timeFraction * 100
                    this.frame = requestAnimationFrame(this.animate.bind(this))
                } else {
                    timeFraction = 1
                    this.active = (this.active + 1) % this.items.length
                }
            },
            heightFix() {
                this.$nextTick(() => {
                    this.$refs.items.parentElement.style.height = this.$refs.items.children[this.active + 1].clientHeight + 'px'
                })
            }                            
        }))
    })
</script>

Conclusions

In this tutorial, we’ve seen how to make the most of Alpine.js to create an image carousel with progress indicators. Alpine.js’s logic allowed us to build a modular carousel element, just like we would with React or Vue.

If you found this tutorial helpful, make sure to take a look at our Tailwind HTML templates. They are all crafted using this incredible framework!

Oh, and if you want to see how to convert this component to Next.js or Vue, please check out hese links: