How to Create an Image Carousel with Slide Number Counter Using Tailwind CSS and Alpine.js
Published on November 7, 2025 by Michael Andreuzza
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