Create a Carousel with Progress Indicators using Tailwind and Alpine.js
Tags: html, tailwindcss, alpine, tutorial
Published: 2023-12-19 09:23:58

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

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](https://www.homerun.co/) 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:

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

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

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

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

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

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

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

```html
<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](https://cruip.com). They are all crafted using this incredible framework!