Black Weeks. Full Access for 50% OFF. Use code lex50 at checkout.

You'll get every theme available plus future additions. That's 40 themes total.Unlimited projects. Lifetime updates. One payment.

Get full access

How to Create an Image Carousel with Slide Number Counter Using Tailwind CSS and Alpine.js

Published on November 7, 2025 by Michael Andreuzza

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

Did you like this tutorial? Please share it with your friends!