How to Create an Image Carousel with Slide Number Counter Using Tailwind CSS and Alpine.js
Published on November 7, 2025 by Michael Andreuzza · 5 min read
Building an Interactive Image Carousel
Image carousels are a popular way to showcase multiple images in a compact, interactive format. In this tutorial, we’ll build a fully functional carousel with smooth transitions, navigation buttons, a slide counter, and dot indicators—all using Alpine.js and Tailwind CSS.
What You’ll Learn
- How to set up Alpine.js state management for carousel functionality
- Creating smooth opacity transitions between slides
- Building navigation buttons (previous/next)
- Adding a slide counter display
- Implementing dot indicators for direct navigation
- Best practices for accessibility
The HTML & Tailwind Structure
Here’s the complete carousel code.
Breaking Down the Code
1. The Carousel Container
<div class="relative w-full overflow-hidden bg-base-100 rounded-2xl"></div>
relative: Establishes a positioning context for absolute elements insideoverflow-hidden: Clips any content that extends beyond the carousel boundsrounded-2xl: Adds rounded corners for a modern look
2. Slides Container
<div class="relative w-full h-96 md:h-[500px]"></div>
- Sets the carousel height (
h-96on mobile,h-[500px]on larger screens) relativepositioning allows child images to be positioned absolutely
3. Individual Slides
<template x-for="(slide, index) in slides" :key="index">
<div
:class="currentSlide === index ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 transition-opacity duration-500 ease-in-out"
></div
></template>
x-for: Alpine loops through each slide:classbinding: Shows the current slide atopacity-100, hides others atopacity-0transition-opacity duration-500: Creates a smooth fade effect over 500msinset-0: Positions the image to fill the entire container
4. Image with Dynamic Sources
<img
:src="slide === 0 ? 'url1' : slide === 1 ? 'url2' : 'url3'"
class="w-full h-full object-cover"
/>
- Uses ternary operators to load different images based on the slide index
object-cover: Ensures the image fills the container without distortion
5. Navigation Buttons
<div class="absolute left-0 top-1/2 -translate-y-1/2 z-20"></div>
absolute left-0 top-1/2: Positions the button on the left edge, vertically centered-translate-y-1/2: Offsets it by -50% to perfectly center itz-20: Ensures buttons appear above the images@click="prev()"and@click="next()": Triggers navigation functions
6. Slide Counter
<div
class="absolute top-4 right-4 z-20 px-3 py-2 bg-black/50 text-white text-sm font-medium rounded-full"
>
<span x-text="currentSlide + 1"></span> / <span x-text="slides"></span>
</div>
- Displays the current slide number and total slides
bg-black/50: Semi-transparent black backgroundrounded-full: Pill-shaped containercurrentSlide + 1: Shows 1-based indexing (1, 2, 3…) instead of 0-based
7. Dot Indicators
<template x-for="(slide, index) in slides" :key="index">
<button
@click="goToSlide(index)"
:class="currentSlide === index ? 'bg-accent-500' : 'bg-base-300 hover:bg-base-400'"
class="w-3 h-3 rounded-full transition-colors duration-200"
></button
></template>
- Loops through slides creating a dot for each
- Active dot:
bg-accent-500(highlighted color) - Inactive dots:
bg-base-300with hover state @click="goToSlide(index)": Jump directly to any sliderounded-full: Creates perfect circles
Alpine.js State Management
To make this work, you need Alpine.js data:
x-data="{
currentSlide: 0,
slides: 3,
next() {
this.currentSlide = (this.currentSlide + 1) % this.slides;
},
prev() {
this.currentSlide = (this.currentSlide - 1 + this.slides) % this.slides;
},
goToSlide(index) {
this.currentSlide = index;
}
}"
currentSlide: Tracks which slide is being displayedslides: Total number of slides (3 in this example)next(): Moves to the next slide, wrapping around to the first if at the endprev(): Moves to the previous slide, wrapping to the last if at the beginninggoToSlide(index): Jumps directly to a specific slide
Key Features Explained
Smooth Transitions: The transition-opacity duration-500 ease-in-out class creates a fade effect over 500 milliseconds.
Responsive Height: Uses Tailwind’s responsive classes (h-96 md:h-[500px]) to adjust carousel height on different screen sizes.
Accessibility: Includes aria-label, aria-current, and proper semantic HTML for screen readers.
Circular Navigation: The modulo operator (%) ensures the carousel loops—going forward from the last slide returns to the first, and vice versa.
Customization Ideas
- Change
duration-500toduration-300for faster transitions - Modify image URLs to use your own images or dynamic data
- Add auto-play by using
setInterval()to callnext()automatically - Replace opacity transitions with CSS transforms for slide animations
- Add keyboard navigation with arrow keys
- Include swipe gestures for mobile devices
This carousel is production-ready and works great for portfolios, product showcases, testimonials, and hero sections. Enjoy!
Complete Code
<!-- Carousel Container -->
<div class="relative w-full overflow-hidden bg-base-100 rounded-2xl">
<!-- Slides -->
<div class="relative w-full h-96 md:h-[500px]">
<template x-for="(slide, index) in slides" :key="index">
<div
:class="currentSlide === index ? 'opacity-100' : 'opacity-0'"
class="absolute inset-0 transition-opacity duration-500 ease-in-out"
>
<img
:src="slide === 0 ? 'https://images.unsplash.com/photo-1570129477492-45c003edd2be?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2070' : slide === 1 ? 'https://plus.unsplash.com/premium_photo-1733760125497-cd51a05afc6c?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2071' : 'https://plus.unsplash.com/premium_photo-1733760125031-62a1611f02db?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2071'"
:alt="slide === 0 ? 'Blue Aurora' : slide === 1 ? 'Blue Blob' : 'Gradient Animation'"
class="w-full h-full object-cover"
/>
</div>
</template>
</div>
<!-- Navigation Buttons -->
<div class="absolute left-0 top-1/2 -translate-y-1/2 z-20">
<button
variant="muted"
size="md"
iconOnly
@click="prev()"
aria-label="Previous slide"
class="rounded-r-lg rounded-l-none"
>
<fragment slot="icon">
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
</fragment>
</button>
</div>
<div class="absolute right-0 top-1/2 -translate-y-1/2 z-20">
<button
variant="muted"
size="md"
iconOnly
@click="next()"
aria-label="Next slide"
class="rounded-l-lg rounded-r-none"
>
<fragment slot="icon">
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</fragment>
</button>
</div>
<!-- Slide Counter -->
<div
class="absolute top-4 right-4 z-20 px-3 py-2 bg-black/50 text-white text-sm font-medium rounded-full"
>
<span x-text="currentSlide + 1"></span> / <span x-text="slides"></span>
</div>
</div>
<!-- Dot Indicators -->
<div class="flex justify-center gap-2 mt-6">
<template x-for="(slide, index) in slides" :key="index">
<button
@click="goToSlide(index)"
:class="currentSlide === index ? 'bg-accent-500' : 'bg-base-300 hover:bg-base-400'"
class="w-3 h-3 rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2"
:aria-label="`Go to slide ${index + 1}`"
:aria-current="currentSlide === index"
></button>
</template>
</div> /Michael Andreuzza