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

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

<div class="relative w-full overflow-hidden bg-base-100 rounded-2xl"></div>
  • relative: Establishes a positioning context for absolute elements inside
  • overflow-hidden: Clips any content that extends beyond the carousel bounds
  • rounded-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-96 on mobile, h-[500px] on larger screens)
  • relative positioning 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
  • :class binding: Shows the current slide at opacity-100, hides others at opacity-0
  • transition-opacity duration-500: Creates a smooth fade effect over 500ms
  • inset-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 it
  • z-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 background
  • rounded-full: Pill-shaped container
  • currentSlide + 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-300 with hover state
  • @click="goToSlide(index)": Jump directly to any slide
  • rounded-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 displayed
  • slides: Total number of slides (3 in this example)
  • next(): Moves to the next slide, wrapping around to the first if at the end
  • prev(): Moves to the previous slide, wrapping to the last if at the beginning
  • goToSlide(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-500 to duration-300 for faster transitions
  • Modify image URLs to use your own images or dynamic data
  • Add auto-play by using setInterval() to call next() 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